├── .gitignore ├── README.rst ├── images ├── add_poll_need_verbose_name_for_pub_date.png ├── admin03t.png ├── admin05t.png ├── django-pony.jpg ├── django_admin_logged_in.png ├── django_admin_login.png ├── django_admin_poll_object_needs_verbose_name.png ├── django_it_worked_default_page.png ├── django_pony2.png ├── kid_goat.png ├── no_such_table_error.png ├── page_not_found_debug_error.png └── testing-goat.jpg ├── mysite ├── fts │ ├── __init__.py │ ├── fixtures │ │ └── admin_user.json │ ├── models.py │ ├── tests.py │ └── views.py ├── manage.py ├── mysite │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── polls │ ├── __init__.py │ ├── admin.py │ ├── forms.py │ ├── models.py │ ├── templates │ ├── 500.html │ ├── home.html │ └── poll.html │ ├── tests │ ├── __init__.py │ ├── test_forms.py │ ├── test_models.py │ └── test_views.py │ └── views.py ├── talk_italian.rst ├── tdd-feedback.txt ├── tutorial01.rst ├── tutorial02.rst ├── tutorial03.rst ├── tutorial04.rst ├── tutorial05.rst ├── tutorial06.rst └── workshop.rst /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | tags 3 | selenium-server-standalone-2.*.jar 4 | database.sqlite 5 | ft_database.sqlite 6 | django_server_logfile.txt 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Update - this tutorial is out of date, and no longer being updated 2 | ------------------------------------------------------------------ 3 | 4 | This tutorial dates back to 2012, and was originally written for Django 1.3. It 5 | is totally out of date now, and I'm not maintaining it. If you're looking for 6 | (what I think is) a better introduction to TDD and Django, 7 | please check out 8 | 9 | http://www.obeythetestinggoat.com 10 | 11 | The site for my book, which contains everything that used to be in here, and 12 | much, much more, and is available **entirely free** (but also for money if 13 | you like) 14 | 15 | 16 | For hysterical purposes, here is some of the old blurb. 17 | 18 | The Concept 19 | ----------- 20 | 21 | This idea was to provide an introduction to Test-Driven web development using 22 | Django (and Python). Essentially, we run through the same material as the 23 | official Django tutorial, but instead of 'just' writing code, we write tests 24 | first at each stage - both "functional tests", in which we actually pretend to 25 | be a user, and drive a real web browser, as well as "unit tests", which help us 26 | to design and piece together the individual working parts of the code. 27 | 28 | The tutorial uses the new release of Django (1.4), and covers 95% of what's covered 29 | in the official Django tutorial. Suggestions, comments and feedback are gratefully 30 | received... What should I do next?? 31 | 32 | 33 | Who is this for? 34 | ---------------- 35 | 36 | Maybe you've done a bit of Python programming, and you're thinking of learning 37 | Django, and you want to do it "properly". Maybe you've done some test-driven 38 | web development in another language, and you want to find out about how it all 39 | works in the Python world. Most importantly, you've heard of, or had experience 40 | of, working on a project where complexity has started to get the better of you, 41 | where you're scared to make changes, and you wished there had been better 42 | testing from the get-go. 43 | 44 | 45 | Who is this not for? 46 | -------------------- 47 | 48 | If you know Python, Django and Selenium inside out, I suspect there's better things 49 | that you can do with your time. If you're a total beginner programmer, I also 50 | think it might not be quite right for you - you might do better to get a couple 51 | of other tutorials under your belt first. If you're already a programmer, but 52 | have never tried Python, you'll be fine, but I thoroughly recommend the excellent 53 | "Dive into Python" for a bit more of an insight into the language itself. 54 | 55 | 56 | 57 | Why should you listen to me? 58 | ---------------------------- 59 | 60 | I was lucky enough to get my first "proper" software development job about a 61 | year ago with a bunch of Extreme Programming fanatics, who've thoroughly 62 | inculcated me into their cult of Test-Driven development. Believe me when I 63 | say I'm contrary enough to have questioned every single practice, challenged 64 | every single decision, moaned about every extra minute spent doing "pointless" 65 | tests instead of writing "proper" code. But I've come round to the idea now, 66 | and whenever I've had to go back to some of my old projects which don't have 67 | tests, boy have I ever realised the wisdom of the approach. 68 | 69 | So, I've learnt from some really good people, and the learning process is still 70 | fresh in my mind, so I hope I'll be good at communicating it. Most importantly, 71 | I still have the passion of a recent convert, so I hope I'll be good at conveying 72 | some enthusiasm. 73 | 74 | 75 | 76 | Why Test-Driven Development? 77 | ---------------------------- 78 | 79 | The thing is, when you start out on a small project, you don't really need tests. 80 | Tests take time to write - as much as, if not more than, the actual code for your 81 | application. You've got to learn testing frameworks, and they inevitably come 82 | with a whole host of their own problems (and this applies especially to web-browser 83 | testing. oh boy.). Meanwhile, you know you could just knock out a few lines of 84 | code, and your application would be off the ground, and would start to be 85 | useful. There are deadlines! Clients who are paying for your time! Or maybe 86 | just the smell of that `Internet money`, and arriving late to the party means 87 | none of it will be for you! 88 | 89 | Well, that's all true. At first. At first, it's obvious whether everything 90 | works. You can just log into the dev server, click around a bit, and see 91 | whether everything looks OK. And changing this bit of code over `here`, is 92 | only ever going to affect these things `here` and `here`... So it's easy to 93 | change stuff and see if you've broken anything... 94 | 95 | But as soon as your project gets slightly larger, complexity rears its ugly 96 | head. Combinatorial explosion starts to make you its bitch. Changes start to 97 | have unpredictable effects. You start to worry about making changes to that 98 | thing over there, because you wrote it ages ago, and you're pretty sure other 99 | things depend on it... best to just use it as it is, even though it's hideously 100 | ugly... Well, anyway, changing this thing over `here` shouldn't affect too much 101 | stuff. I'll just run through the main bits of the site to check... Can't possibly 102 | check everything though... Oh well, I'll just deploy and see if anyone complains... 103 | 104 | Automated tests can save you from this fate. If you have automated tests, you can 105 | know for sure whether or not your latest changes broke anything. With tests, 106 | you're free to keep refactoring your code, to keep trying out new ways to optimise 107 | things, to keep adding new functionality, safe in the knowledge that your tests 108 | will let you know if you get things wrong. 109 | 110 | Look, that's got to be enough evangelising. If you don't believe me, just ask 111 | someone else with experience. They know. Now, onto the practicals. 112 | 113 | 114 | Convinced? Get on with part 1 of the tutorial then! 115 | 116 | http://harry.pythonanywhere.com/tutorial/1/ 117 | 118 | 119 | USEFUL LINKS 120 | ------------ 121 | 122 | https://github.com/hjwp/Test-Driven-Django-Tutorial 123 | 124 | https://docs.djangoproject.com/en/1.4/intro/tutorial02/ 125 | 126 | http://seleniumhq.org/docs/03_webdriver.html 127 | 128 | http://code.google.com/p/selenium/source/browse/trunk/py/selenium/webdriver/remote/webdriver.py 129 | 130 | http://code.google.com/p/selenium/source/browse/trunk/py/selenium/webdriver/remote/webelement.py 131 | 132 | http://www.pythonanywhere.com/ 133 | 134 | LICENSE 135 | ------- 136 | 137 | Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. 138 | 139 | http://creativecommons.org/licenses/by-nc-sa/3.0/ 140 | 141 | -------------------------------------------------------------------------------- /images/add_poll_need_verbose_name_for_pub_date.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/add_poll_need_verbose_name_for_pub_date.png -------------------------------------------------------------------------------- /images/admin03t.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/admin03t.png -------------------------------------------------------------------------------- /images/admin05t.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/admin05t.png -------------------------------------------------------------------------------- /images/django-pony.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/django-pony.jpg -------------------------------------------------------------------------------- /images/django_admin_logged_in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/django_admin_logged_in.png -------------------------------------------------------------------------------- /images/django_admin_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/django_admin_login.png -------------------------------------------------------------------------------- /images/django_admin_poll_object_needs_verbose_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/django_admin_poll_object_needs_verbose_name.png -------------------------------------------------------------------------------- /images/django_it_worked_default_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/django_it_worked_default_page.png -------------------------------------------------------------------------------- /images/django_pony2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/django_pony2.png -------------------------------------------------------------------------------- /images/kid_goat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/kid_goat.png -------------------------------------------------------------------------------- /images/no_such_table_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/no_such_table_error.png -------------------------------------------------------------------------------- /images/page_not_found_debug_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/page_not_found_debug_error.png -------------------------------------------------------------------------------- /images/testing-goat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/images/testing-goat.jpg -------------------------------------------------------------------------------- /mysite/fts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/mysite/fts/__init__.py -------------------------------------------------------------------------------- /mysite/fts/fixtures/admin_user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "auth.user", 5 | "fields": { 6 | "username": "admin", 7 | "first_name": "", 8 | "last_name": "", 9 | "is_active": true, 10 | "is_superuser": true, 11 | "is_staff": true, 12 | "last_login": "2012-04-09T15:04:34.761Z", 13 | "groups": [], 14 | "user_permissions": [], 15 | "password": "pbkdf2_sha256$10000$2FczgrOwtXZh$95PfUdz66q7vUAImzBpP1aOIp115jE30lRGQRSDz23Q=", 16 | "email": "hjwp2@cantab.net", 17 | "date_joined": "2012-04-09T15:04:34.761Z" 18 | } 19 | } 20 | ] -------------------------------------------------------------------------------- /mysite/fts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /mysite/fts/tests.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from django.test import LiveServerTestCase 3 | from selenium import webdriver 4 | from selenium.webdriver.common.keys import Keys 5 | 6 | PollInfo = namedtuple('PollInfo', ['question', 'choices']) 7 | POLL1 = PollInfo( 8 | question="How awesome is Test-Driven Development?", 9 | choices=[ 10 | 'Very awesome', 11 | 'Quite awesome', 12 | 'Moderately awesome', 13 | ], 14 | ) 15 | POLL2 = PollInfo( 16 | question="Which workshop treat do you prefer?", 17 | choices=[ 18 | 'Beer', 19 | 'Pizza', 20 | 'The Acquisition of Knowledge', 21 | ], 22 | ) 23 | 24 | 25 | class PollsTest(LiveServerTestCase): 26 | fixtures = ['admin_user.json'] 27 | 28 | def setUp(self): 29 | self.browser = webdriver.Firefox() 30 | self.browser.implicitly_wait(3) 31 | 32 | def tearDown(self): 33 | self.browser.quit() 34 | 35 | def test_can_create_new_poll_via_admin_site(self): 36 | # Gertrude opens her web browser, and goes to the admin page 37 | self.browser.get(self.live_server_url + '/admin/') 38 | 39 | # She sees the familiar 'Django administration' heading 40 | body = self.browser.find_element_by_tag_name('body') 41 | self.assertIn('Django administration', body.text) 42 | 43 | # She types in her username and passwords and hits return 44 | username_field = self.browser.find_element_by_name('username') 45 | username_field.send_keys('admin') 46 | 47 | password_field = self.browser.find_element_by_name('password') 48 | password_field.send_keys('adm1n') 49 | password_field.send_keys(Keys.RETURN) 50 | 51 | # her username and password are accepted, and she is taken to 52 | # the Site Administration page 53 | body = self.browser.find_element_by_tag_name('body') 54 | self.assertIn('Site administration', body.text) 55 | 56 | # She now sees a couple of hyperlink that says "Polls" 57 | polls_links = self.browser.find_elements_by_link_text('Polls') 58 | self.assertEquals(len(polls_links), 2) 59 | 60 | # The second one looks more exciting, so she clicks it 61 | polls_links[1].click() 62 | 63 | # She is taken to the polls listing page, which shows she has 64 | # no polls yet 65 | body = self.browser.find_element_by_tag_name('body') 66 | self.assertIn('0 polls', body.text) 67 | 68 | # She sees a link to 'add' a new poll, so she clicks it 69 | new_poll_link = self.browser.find_element_by_link_text('Add poll') 70 | new_poll_link.click() 71 | 72 | # She sees some input fields for "Question" and "Date published" 73 | body = self.browser.find_element_by_tag_name('body') 74 | self.assertIn('Question:', body.text) 75 | self.assertIn('Date published:', body.text) 76 | # She types in an interesting question for the Poll 77 | question_field = self.browser.find_element_by_name('question') 78 | question_field.send_keys("How awesome is Test-Driven Development?") 79 | 80 | # She sets the date and time of publication - it'll be a new year's 81 | # poll! 82 | date_field = self.browser.find_element_by_name('pub_date_0') 83 | date_field.send_keys('01/01/12') 84 | time_field = self.browser.find_element_by_name('pub_date_1') 85 | time_field.send_keys('00:00') 86 | 87 | # She sees she can enter choices for the Poll. She adds three 88 | choice_1 = self.browser.find_element_by_name('choice_set-0-choice') 89 | choice_1.send_keys('Very awesome') 90 | choice_2 = self.browser.find_element_by_name('choice_set-1-choice') 91 | choice_2.send_keys('Quite awesome') 92 | choice_3 = self.browser.find_element_by_name('choice_set-2-choice') 93 | choice_3.send_keys('Moderately awesome') 94 | 95 | # Gertrude clicks the save button 96 | save_button = self.browser.find_element_by_css_selector("input[value='Save']") 97 | save_button.click() 98 | 99 | # She is returned to the "Polls" listing, where she can see her 100 | # new poll, listed as a clickable link 101 | new_poll_links = self.browser.find_elements_by_link_text( 102 | "How awesome is Test-Driven Development?" 103 | ) 104 | self.assertEquals(len(new_poll_links), 1) 105 | 106 | # Satisfied, she goes back to sleep 107 | 108 | 109 | def _setup_polls_via_admin(self): 110 | # Gertrude logs into the admin site 111 | self.browser.get(self.live_server_url + '/admin/') 112 | username_field = self.browser.find_element_by_name('username') 113 | username_field.send_keys('admin') 114 | password_field = self.browser.find_element_by_name('password') 115 | password_field.send_keys('adm1n') 116 | password_field.send_keys(Keys.RETURN) 117 | 118 | # She has a number of polls to enter. For each one, she: 119 | for poll_info in [POLL1, POLL2]: 120 | # Follows the link to the Polls app, and adds a new Poll 121 | self.browser.find_elements_by_link_text('Polls')[1].click() 122 | self.browser.find_element_by_link_text('Add poll').click() 123 | 124 | # Enters its name, and uses the 'today' and 'now' buttons to set 125 | # the publish date 126 | question_field = self.browser.find_element_by_name('question') 127 | question_field.send_keys(poll_info.question) 128 | self.browser.find_element_by_link_text('Today').click() 129 | self.browser.find_element_by_link_text('Now').click() 130 | 131 | # Sees she can enter choices for the Poll on this same page, 132 | # so she does 133 | for i, choice_text in enumerate(poll_info.choices): 134 | choice_field = self.browser.find_element_by_name('choice_set-%d-choice' % i) 135 | choice_field.send_keys(choice_text) 136 | 137 | # Saves her new poll 138 | save_button = self.browser.find_element_by_css_selector("input[value='Save']") 139 | save_button.click() 140 | 141 | # Is returned to the "Polls" listing, where she can see her 142 | # new poll, listed as a clickable link by its name 143 | new_poll_links = self.browser.find_elements_by_link_text( 144 | poll_info.question 145 | ) 146 | self.assertEquals(len(new_poll_links), 1) 147 | 148 | # She goes back to the root of the admin site 149 | self.browser.get(self.live_server_url + '/admin/') 150 | 151 | # She logs out of the admin site 152 | self.browser.find_element_by_link_text('Log out').click() 153 | 154 | 155 | def test_voting_on_a_new_poll(self): 156 | # First, Gertrude the administrator logs into the admin site and 157 | # creates a couple of new Polls, and their response choices 158 | self._setup_polls_via_admin() 159 | 160 | # Now, Herbert the regular user goes to the homepage of the site. He 161 | # sees a list of polls. 162 | self.browser.get(self.live_server_url) 163 | heading = self.browser.find_element_by_tag_name('h1') 164 | self.assertEquals(heading.text, 'Polls') 165 | 166 | # He clicks on the link to the first Poll, which is called 167 | # 'How awesome is test-driven development?' 168 | first_poll_title = 'How awesome is Test-Driven Development?' 169 | self.browser.find_element_by_link_text(first_poll_title).click() 170 | 171 | # He is taken to a poll 'results' page, which says 172 | # "no-one has voted on this poll yet" 173 | main_heading = self.browser.find_element_by_tag_name('h1') 174 | self.assertEquals(main_heading.text, 'Poll Results') 175 | sub_heading = self.browser.find_element_by_tag_name('h2') 176 | self.assertEquals(sub_heading.text, first_poll_title) 177 | body = self.browser.find_element_by_tag_name('body') 178 | self.assertIn('No-one has voted on this poll yet', body.text) 179 | 180 | # He also sees a form, which offers him several choices. 181 | # There are three options with radio buttons 182 | choice_inputs = self.browser.find_elements_by_css_selector( 183 | "input[type='radio']" 184 | ) 185 | self.assertEquals(len(choice_inputs), 3) 186 | 187 | # The buttons have labels to explain them 188 | choice_labels = self.browser.find_elements_by_tag_name('label') 189 | choices_text = [c.text for c in choice_labels] 190 | self.assertEquals(choices_text, [ 191 | 'Vote:', # this label is auto-generated for the whole form 192 | 'Very awesome', 193 | 'Quite awesome', 194 | 'Moderately awesome', 195 | ]) 196 | # He decided to select "very awesome", which is answer #1 197 | chosen = self.browser.find_element_by_css_selector( 198 | "input[value='1']" 199 | ) 200 | chosen.click() 201 | 202 | # Herbert clicks 'submit' 203 | self.browser.find_element_by_css_selector( 204 | "input[type='submit']" 205 | ).click() 206 | 207 | # The page refreshes, and he sees that his choice 208 | # has updated the results. they now say 209 | # "100 %: very awesome". 210 | body_text = self.browser.find_element_by_tag_name('body').text 211 | self.assertIn('100 %: Very awesome', body_text) 212 | 213 | # The page also says "1 vote" 214 | self.assertIn('1 vote', body_text) 215 | 216 | # But not "1 votes" -- Herbert is impressed at the attention to detail 217 | self.assertNotIn('1 votes', body_text) 218 | 219 | # Herbert suspects that the website isn't very well protected 220 | # against people submitting multiple votes yet, so he tries 221 | # to do a little astroturfing 222 | self.browser.find_element_by_css_selector("input[value='1']").click() 223 | self.browser.find_element_by_css_selector("input[type='submit']").click() 224 | 225 | # The page refreshes, and he sees that his choice has updated the 226 | # results. it still says # "100 %: very awesome". 227 | body_text = self.browser.find_element_by_tag_name('body').text 228 | self.assertIn('100 %: Very awesome', body_text) 229 | 230 | # But the page now says "2 votes" 231 | self.assertIn('2 votes', body_text) 232 | 233 | # Cackling manically over his l33t haxx0ring skills, he tries 234 | # voting for a different choice 235 | self.browser.find_element_by_css_selector("input[value='2']").click() 236 | self.browser.find_element_by_css_selector("input[type='submit']").click() 237 | 238 | # Now, the percentages update, as well as the votes 239 | body_text = self.browser.find_element_by_tag_name('body').text 240 | self.assertIn('67 %: Very awesome', body_text) 241 | self.assertIn('33 %: Quite awesome', body_text) 242 | self.assertIn('3 votes', body_text) 243 | 244 | # Satisfied, he goes back to sleep 245 | 246 | 247 | -------------------------------------------------------------------------------- /mysite/fts/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /mysite/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /mysite/mysite/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/mysite/mysite/__init__.py -------------------------------------------------------------------------------- /mysite/mysite/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for mysite project. 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | ADMINS = ( 7 | # ('Your Name', 'your_email@example.com'), 8 | ) 9 | 10 | MANAGERS = ADMINS 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 15 | 'NAME': 'database.sqlite', # Or path to database file if using sqlite3. 16 | 'USER': '', # Not used with sqlite3. 17 | 'PASSWORD': '', # Not used with sqlite3. 18 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 19 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 20 | } 21 | } 22 | 23 | # Local time zone for this installation. Choices can be found here: 24 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 25 | # although not all choices may be available on all operating systems. 26 | # On Unix systems, a value of None will cause Django to use the same 27 | # timezone as the operating system. 28 | # If running in a Windows environment this must be set to the same as your 29 | # system time zone. 30 | TIME_ZONE = 'America/Chicago' 31 | 32 | # Language code for this installation. All choices can be found here: 33 | # http://www.i18nguy.com/unicode/language-identifiers.html 34 | LANGUAGE_CODE = 'en-us' 35 | 36 | SITE_ID = 1 37 | 38 | # If you set this to False, Django will make some optimizations so as not 39 | # to load the internationalization machinery. 40 | USE_I18N = True 41 | 42 | # If you set this to False, Django will not format dates, numbers and 43 | # calendars according to the current locale. 44 | USE_L10N = True 45 | 46 | # If you set this to False, Django will not use timezone-aware datetimes. 47 | USE_TZ = True 48 | 49 | # Absolute filesystem path to the directory that will hold user-uploaded files. 50 | # Example: "/home/media/media.lawrence.com/media/" 51 | MEDIA_ROOT = '' 52 | 53 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 54 | # trailing slash. 55 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 56 | MEDIA_URL = '' 57 | 58 | # Absolute path to the directory static files should be collected to. 59 | # Don't put anything in this directory yourself; store your static files 60 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 61 | # Example: "/home/media/media.lawrence.com/static/" 62 | STATIC_ROOT = '' 63 | 64 | # URL prefix for static files. 65 | # Example: "http://media.lawrence.com/static/" 66 | STATIC_URL = '/static/' 67 | 68 | # Additional locations of static files 69 | STATICFILES_DIRS = ( 70 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 71 | # Always use forward slashes, even on Windows. 72 | # Don't forget to use absolute paths, not relative paths. 73 | ) 74 | 75 | # List of finder classes that know how to find static files in 76 | # various locations. 77 | STATICFILES_FINDERS = ( 78 | 'django.contrib.staticfiles.finders.FileSystemFinder', 79 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 80 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 81 | ) 82 | 83 | # Make this unique, and don't share it with anybody. 84 | SECRET_KEY = 'i%wqqa*4o=seu-sie_oy)fp=*uc0*j)m5p)ju6#g2g=dfp#6ks' 85 | 86 | # List of callables that know how to import templates from various sources. 87 | TEMPLATE_LOADERS = ( 88 | 'django.template.loaders.filesystem.Loader', 89 | 'django.template.loaders.app_directories.Loader', 90 | # 'django.template.loaders.eggs.Loader', 91 | ) 92 | 93 | MIDDLEWARE_CLASSES = ( 94 | 'django.middleware.common.CommonMiddleware', 95 | 'django.contrib.sessions.middleware.SessionMiddleware', 96 | 'django.middleware.csrf.CsrfViewMiddleware', 97 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 98 | 'django.contrib.messages.middleware.MessageMiddleware', 99 | # Uncomment the next line for simple clickjacking protection: 100 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 101 | ) 102 | 103 | ROOT_URLCONF = 'mysite.urls' 104 | 105 | # Python dotted path to the WSGI application used by Django's runserver. 106 | WSGI_APPLICATION = 'mysite.wsgi.application' 107 | 108 | TEMPLATE_DIRS = ( 109 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 110 | # Always use forward slashes, even on Windows. 111 | # Don't forget to use absolute paths, not relative paths. 112 | ) 113 | 114 | INSTALLED_APPS = ( 115 | 'django.contrib.auth', 116 | 'django.contrib.contenttypes', 117 | 'django.contrib.sessions', 118 | 'django.contrib.sites', 119 | 'django.contrib.messages', 120 | 'django.contrib.staticfiles', 121 | # Uncomment the next line to enable the admin: 122 | 'django.contrib.admin', 123 | # Uncomment the next line to enable admin documentation: 124 | # 'django.contrib.admindocs', 125 | 'fts', 126 | 'polls', 127 | ) 128 | 129 | # A sample logging configuration. The only tangible logging 130 | # performed by this configuration is to send an email to 131 | # the site admins on every HTTP 500 error when DEBUG=False. 132 | # See http://docs.djangoproject.com/en/dev/topics/logging for 133 | # more details on how to customize your logging configuration. 134 | LOGGING = { 135 | 'version': 1, 136 | 'disable_existing_loggers': False, 137 | 'filters': { 138 | 'require_debug_false': { 139 | '()': 'django.utils.log.RequireDebugFalse' 140 | } 141 | }, 142 | 'handlers': { 143 | 'mail_admins': { 144 | 'level': 'ERROR', 145 | 'filters': ['require_debug_false'], 146 | 'class': 'django.utils.log.AdminEmailHandler' 147 | } 148 | }, 149 | 'loggers': { 150 | 'django.request': { 151 | 'handlers': ['mail_admins'], 152 | 'level': 'ERROR', 153 | 'propagate': True, 154 | }, 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /mysite/mysite/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | from django.contrib import admin 3 | admin.autodiscover() 4 | 5 | urlpatterns = patterns('', 6 | url(r'^$', 'polls.views.home'), 7 | url(r'^poll/(\d+)/$', 'polls.views.poll'), 8 | url(r'^admin/', include(admin.site.urls)), 9 | ) 10 | 11 | -------------------------------------------------------------------------------- /mysite/mysite/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for mysite project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | application = get_wsgi_application() 25 | 26 | # Apply WSGI middleware here. 27 | # from helloworld.wsgi import HelloWorldApplication 28 | # application = HelloWorldApplication(application) 29 | -------------------------------------------------------------------------------- /mysite/polls/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/mysite/polls/__init__.py -------------------------------------------------------------------------------- /mysite/polls/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from polls.models import Choice, Poll 3 | 4 | class ChoiceInline(admin.StackedInline): 5 | model = Choice 6 | extra = 3 7 | 8 | class PollAdmin(admin.ModelAdmin): 9 | inlines = [ChoiceInline] 10 | 11 | admin.site.register(Poll, PollAdmin) 12 | 13 | -------------------------------------------------------------------------------- /mysite/polls/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | class PollVoteForm(forms.Form): 4 | vote = forms.ChoiceField(widget=forms.RadioSelect()) 5 | 6 | def __init__(self, poll): 7 | forms.Form.__init__(self) 8 | self.fields['vote'].choices = [(c.id, c.choice) for c in poll.choice_set.all()] 9 | -------------------------------------------------------------------------------- /mysite/polls/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class Poll(models.Model): 4 | question = models.CharField(max_length=200) 5 | pub_date = models.DateTimeField(verbose_name='Date published') 6 | 7 | def __unicode__(self): 8 | return self.question 9 | 10 | def total_votes(self): 11 | return sum(c.votes for c in self.choice_set.all()) 12 | 13 | 14 | 15 | class Choice(models.Model): 16 | poll = models.ForeignKey(Poll) 17 | choice = models.CharField(max_length=200) 18 | votes = models.IntegerField(default=0) 19 | 20 | def percentage(self): 21 | try: 22 | return 100.0 * self.votes / self.poll.total_votes() 23 | except ZeroDivisionError: 24 | return 0 25 | -------------------------------------------------------------------------------- /mysite/polls/templates/500.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjwp/Test-Driven-Django-Tutorial/e64149b6ed78c9a6228267306610d7c91ff49049/mysite/polls/templates/500.html -------------------------------------------------------------------------------- /mysite/polls/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Polls

4 | {% for poll in polls %} 5 |

{{ poll.question }}

6 | {% endfor %} 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /mysite/polls/templates/poll.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Poll Results

4 | 5 |

{{poll.question}}

6 | 7 | 12 | 13 | {% if poll.total_votes != 0 %} 14 |

{{ poll.total_votes }} vote{{ poll.total_votes|pluralize }}

15 | {% else %} 16 |

No-one has voted on this poll yet

17 | {% endif %} 18 | 19 |

Add your vote

20 |
21 | {% csrf_token %} 22 | {{form.as_p}} 23 | 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /mysite/polls/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from polls.tests.test_forms import * 2 | from polls.tests.test_models import * 3 | from polls.tests.test_views import * 4 | -------------------------------------------------------------------------------- /mysite/polls/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from polls.forms import PollVoteForm 2 | from django.test import TestCase 3 | from django.utils import timezone 4 | from polls.models import Choice, Poll 5 | 6 | 7 | 8 | class PollsVoteFormTest(TestCase): 9 | 10 | def test_form_renders_poll_choices_as_radio_inputs(self): 11 | # set up a poll with a couple of choices 12 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 13 | poll1.save() 14 | choice1 = Choice(poll=poll1, choice='42', votes=0) 15 | choice1.save() 16 | choice2 = Choice(poll=poll1, choice='The Ultimate Answer', votes=0) 17 | choice2.save() 18 | 19 | # set up another poll to make sure we only see the right choices 20 | poll2 = Poll(question='time', pub_date=timezone.now()) 21 | poll2.save() 22 | choice3 = Choice(poll=poll2, choice='PM', votes=0) 23 | choice3.save() 24 | 25 | # build a voting form for poll1 26 | form = PollVoteForm(poll=poll1) 27 | 28 | # check it has a single field called 'vote', which has right choices: 29 | self.assertEquals(form.fields.keys(), ['vote']) 30 | 31 | # choices are tuples in the format (choice_number, choice_text): 32 | self.assertEquals(form.fields['vote'].choices, [ 33 | (choice1.id, choice1.choice), 34 | (choice2.id, choice2.choice), 35 | ]) 36 | 37 | # check it uses radio inputs to render 38 | self.assertIn('input type="radio"', form.as_p()) 39 | 40 | 41 | def test_page_shows_choices_using_form(self): 42 | # set up a poll with choices 43 | poll1 = Poll(question='time', pub_date=timezone.now()) 44 | poll1.save() 45 | choice1 = Choice(poll=poll1, choice="PM", votes=0) 46 | choice1.save() 47 | choice2 = Choice(poll=poll1, choice="Gardener's", votes=0) 48 | choice2.save() 49 | 50 | response = self.client.get('/poll/%d/' % (poll1.id, )) 51 | 52 | # check we've passed in a form of the right type 53 | self.assertTrue(isinstance(response.context['form'], PollVoteForm)) 54 | 55 | # and check the check the form is being used in the template, 56 | # by checking for the choice text 57 | self.assertIn(choice1.choice, response.content.replace(''', "'")) 58 | self.assertIn(choice2.choice, response.content.replace(''', "'")) 59 | -------------------------------------------------------------------------------- /mysite/polls/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.utils import timezone 3 | from polls.models import Choice, Poll 4 | 5 | class PollModelTest(TestCase): 6 | def test_creating_a_new_poll_and_saving_it_to_the_database(self): 7 | # start by creating a new Poll object with its "question" and 8 | # "pub_date" attributes set 9 | poll = Poll() 10 | poll.question = "What's up?" 11 | poll.pub_date = timezone.now() 12 | 13 | # check we can save it to the database 14 | poll.save() 15 | 16 | # now check we can find it in the database again 17 | all_polls_in_database = Poll.objects.all() 18 | self.assertEquals(len(all_polls_in_database), 1) 19 | only_poll_in_database = all_polls_in_database[0] 20 | self.assertEquals(only_poll_in_database, poll) 21 | 22 | # and check that it's saved its two attributes: question and pub_date 23 | self.assertEquals(only_poll_in_database.question, "What's up?") 24 | self.assertEquals(only_poll_in_database.pub_date, poll.pub_date) 25 | 26 | 27 | def test_verbose_name_for_pub_date(self): 28 | for field in Poll._meta.fields: 29 | if field.name == 'pub_date': 30 | self.assertEquals(field.verbose_name, 'Date published') 31 | 32 | 33 | def test_poll_objects_are_named_after_their_question(self): 34 | p = Poll() 35 | p.question = 'How is babby formed?' 36 | self.assertEquals(unicode(p), 'How is babby formed?') 37 | 38 | 39 | def test_poll_can_tell_you_its_total_number_of_votes(self): 40 | p = Poll(question='where',pub_date=timezone.now()) 41 | p.save() 42 | c1 = Choice(poll=p,choice='here',votes=0) 43 | c1.save() 44 | c2 = Choice(poll=p,choice='there',votes=0) 45 | c2.save() 46 | 47 | self.assertEquals(p.total_votes(), 0) 48 | 49 | c1.votes = 1000 50 | c1.save() 51 | c2.votes = 22 52 | c2.save() 53 | self.assertEquals(p.total_votes(), 1022) 54 | 55 | 56 | 57 | class ChoiceModelTest(TestCase): 58 | 59 | def test_creating_some_choices_for_a_poll(self): 60 | # start by creating a new Poll object 61 | poll = Poll() 62 | poll.question="What's up?" 63 | poll.pub_date = timezone.now() 64 | poll.save() 65 | 66 | # now create a Choice object 67 | choice = Choice() 68 | 69 | # link it with our Poll 70 | choice.poll = poll 71 | 72 | # give it some text 73 | choice.choice = "doin' fine..." 74 | 75 | # and let's say it's had some votes 76 | choice.votes = 3 77 | 78 | # save it 79 | choice.save() 80 | 81 | # try retrieving it from the database, using the poll object's reverse 82 | # lookup 83 | poll_choices = poll.choice_set.all() 84 | self.assertEquals(poll_choices.count(), 1) 85 | 86 | # finally, check its attributes have been saved 87 | choice_from_db = poll_choices[0] 88 | self.assertEquals(choice_from_db, choice) 89 | self.assertEquals(choice_from_db.choice, "doin' fine...") 90 | self.assertEquals(choice_from_db.votes, 3) 91 | 92 | 93 | def test_choice_defaults(self): 94 | choice = Choice() 95 | self.assertEquals(choice.votes, 0) 96 | 97 | 98 | def test_choice_can_calculate_its_own_percentage_of_votes(self): 99 | poll = Poll(question='who?', pub_date=timezone.now()) 100 | poll.save() 101 | choice1 = Choice(poll=poll,choice='me',votes=2) 102 | choice1.save() 103 | choice2 = Choice(poll=poll,choice='you',votes=1) 104 | choice2.save() 105 | 106 | self.assertEquals(choice1.percentage(), 100 * 2 / 3.0) 107 | self.assertEquals(choice2.percentage(), 100 * 1 / 3.0) 108 | 109 | # also check 0-votes case 110 | choice1.votes = 0 111 | choice1.save() 112 | choice2.votes = 0 113 | choice2.save() 114 | self.assertEquals(choice1.percentage(), 0) 115 | self.assertEquals(choice2.percentage(), 0) 116 | -------------------------------------------------------------------------------- /mysite/polls/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from django.test import TestCase 3 | from django.utils import timezone 4 | from polls.models import Choice, Poll 5 | 6 | 7 | class HomePageViewTest(TestCase): 8 | 9 | def test_root_url_shows_links_to_all_polls(self): 10 | # set up some polls 11 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 12 | poll1.save() 13 | poll2 = Poll(question='life, the universe and everything', pub_date=timezone.now()) 14 | poll2.save() 15 | 16 | response = self.client.get('/') 17 | 18 | # check we've used the right template 19 | self.assertTemplateUsed(response, 'home.html') 20 | 21 | # check we've passed the polls to the template 22 | polls_in_context = response.context['polls'] 23 | self.assertEquals(list(polls_in_context), [poll1, poll2]) 24 | 25 | # check the poll names appear on the page 26 | self.assertIn(poll1.question, response.content) 27 | self.assertIn(poll2.question, response.content) 28 | 29 | # check the page also contains the urls to individual polls pages 30 | poll1_url = reverse('polls.views.poll', args=[poll1.id,]) 31 | self.assertIn(poll1_url, response.content) 32 | poll2_url = reverse('polls.views.poll', args=[poll2.id,]) 33 | self.assertIn(poll2_url, response.content) 34 | 35 | 36 | 37 | class SinglePollViewTest(TestCase): 38 | 39 | def test_page_shows_poll_title_and_no_votes_message(self): 40 | # set up two polls, to check the right one is displayed 41 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 42 | poll1.save() 43 | poll2 = Poll(question='life, the universe and everything', pub_date=timezone.now()) 44 | poll2.save() 45 | 46 | response = self.client.get('/poll/%d/' % (poll2.id, )) 47 | 48 | # check we've used the poll template 49 | self.assertTemplateUsed(response, 'poll.html') 50 | 51 | # check we've passed the right poll into the context 52 | self.assertEquals(response.context['poll'], poll2) 53 | 54 | # check the poll's question appears on the page 55 | self.assertIn(poll2.question, response.content) 56 | 57 | # check our 'no votes yet' message appears 58 | self.assertIn('No-one has voted on this poll yet', response.content) 59 | 60 | 61 | def test_view_shows_percentage_of_votes(self): 62 | # set up a poll with choices 63 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 64 | poll1.save() 65 | choice1 = Choice(poll=poll1, choice='42', votes=1) 66 | choice1.save() 67 | choice2 = Choice(poll=poll1, choice='The Ultimate Answer', votes=2) 68 | choice2.save() 69 | 70 | response = self.client.get('/poll/%d/' % (poll1.id, )) 71 | 72 | # check the percentages of votes are shown, sensibly rounded 73 | self.assertIn('33 %: 42', response.content) 74 | self.assertIn('67 %: The Ultimate Answer', response.content) 75 | 76 | # and that the 'no-one has voted' message is gone 77 | self.assertNotIn('No-one has voted', response.content) 78 | 79 | def test_view_shows_total_votes(self): 80 | # set up a poll with choices 81 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 82 | poll1.save() 83 | choice1 = Choice(poll=poll1, choice='42', votes=1) 84 | choice1.save() 85 | choice2 = Choice(poll=poll1, choice='The Ultimate Answer', votes=2) 86 | choice2.save() 87 | 88 | response = self.client.get('/poll/%d/' % (poll1.id, )) 89 | self.assertIn('3 votes', response.content) 90 | 91 | # also check we only pluralise "votes" if necessary. details! 92 | choice2.votes = 0 93 | choice2.save() 94 | response = self.client.get('/poll/%d/' % (poll1.id, )) 95 | self.assertIn('1 vote', response.content) 96 | self.assertNotIn('1 votes', response.content) 97 | 98 | 99 | def test_view_can_handle_votes_via_POST(self): 100 | # set up a poll with choices 101 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 102 | poll1.save() 103 | choice1 = Choice(poll=poll1, choice='42', votes=1) 104 | choice1.save() 105 | choice2 = Choice(poll=poll1, choice='The Ultimate Answer', votes=3) 106 | choice2.save() 107 | 108 | # set up our POST data - keys and values are strings 109 | post_data = {'vote': str(choice2.id)} 110 | 111 | # make our request to the view 112 | poll_url = '/poll/%d/' % (poll1.id,) 113 | response = self.client.post(poll_url, data=post_data) 114 | 115 | # retrieve the updated choice from the database 116 | choice_in_db = Choice.objects.get(pk=choice2.id) 117 | 118 | # check it's votes have gone up by 1 119 | self.assertEquals(choice_in_db.votes, 4) 120 | 121 | # always redirect after a POST - even if, in this case, we go back 122 | # to the same page. 123 | self.assertRedirects(response, poll_url) 124 | -------------------------------------------------------------------------------- /mysite/polls/views.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from django.http import HttpResponseRedirect 3 | from django.shortcuts import render 4 | 5 | from polls.forms import PollVoteForm 6 | from polls.models import Choice, Poll 7 | 8 | def home(request): 9 | context = {'polls': Poll.objects.all()} 10 | return render(request, 'home.html', context) 11 | 12 | 13 | def poll(request, poll_id): 14 | if request.method == 'POST': 15 | choice = Choice.objects.get(id=request.POST['vote']) 16 | choice.votes += 1 17 | choice.save() 18 | return HttpResponseRedirect(reverse('polls.views.poll', args=[poll_id,])) 19 | poll = Poll.objects.get(pk=poll_id) 20 | form = PollVoteForm(poll=poll) 21 | return render(request, 'poll.html', {'poll': poll, 'form': form}) 22 | -------------------------------------------------------------------------------- /talk_italian.rst: -------------------------------------------------------------------------------- 1 | OBIDIRE ALLA CAPRA! - Un tutorial TDD con Selenium e Django 2 | =========================================================== 3 | 4 | *Harry Percival* 5 | @hjwp 6 | https://www.obeythetestinggoat.com 7 | https://www.pythonanywhere.com 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Introduzione 27 | ------------ 28 | 29 | * l'Italiano 30 | * l'Inglese 31 | * linguagio technico 32 | * **le domande, per preghiera** 33 | - non fare sempre "si" 34 | - e anche le correzione del' Italiano 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Voi 57 | --- 58 | 59 | * chi conosce Django? Python? TDD? Selenium? 60 | 61 | Io 62 | -- 63 | 64 | * Sestri Levante 65 | * il primo progetto 66 | * Resolver Systems & PythonAnywhere 67 | * il libero 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | Oggi 84 | ---- 85 | 86 | * Come s'inizia il TDD, da 0 87 | * passare a unittest 88 | * test unitari per pagine - tests.py, urls.py, views.py 89 | * forse -- passare al Django Test Client, templates 90 | 91 | 92 | 93 | useful commands:: 94 | 95 | vi functional_tests.py 96 | python functional_tests.py 97 | django-admin.py startproject superlists 98 | cd superlists 99 | mv ../functional_tests.py . 100 | 101 | git init . 102 | git add functional_tests.py manage.py superlists/*.py 103 | git commit -m"initial commit" 104 | 105 | git remote add repo ~/Dropbox/book/source/chapter_9/superlists 106 | git fetch repo 107 | git checkout repo/chapter_3 -- functional_tests.py 108 | 109 | python functional_tests.py 110 | python manage.py startapp lists 111 | vi lists/tests.py 112 | 113 | git checkout repo/chapter_3 -- lists/tests.py 114 | git checkout repo/chapter_3 -- superlists/settings.py 115 | 116 | python manage.py test lists 117 | vi lists/tests.py 118 | python manage.py test lists 119 | python manage.py runserver & 120 | python functional_tests.py 121 | vi functional_tests.py 122 | 123 | git checkout repo/chapter_3_switch_to_django_test_client -- lists/tests.py 124 | 125 | 126 | -------------------------------------------------------------------------------- /tdd-feedback.txt: -------------------------------------------------------------------------------- 1 | Feedback: 2 | --------- 3 | 4 | General: 5 | ! Your site's 404 shows that debug=True... https://docs.djangoproject.com/en/dev/ref/settings/#debug 6 | + consider moving the filename accompanying each script box to the top left corner. i was in Part 3 before i even noticed it, and had been guessing which of the `tests.py` to use. In the case of smaller resolutions, it might not be visible until until all the code has been read. 7 | - especially since there is refactoring involved, it might be helpful to have a version of the code that corresponds to the expected results after each lesson (perhaps as a tag, or branch). This could also help a user jump in at any lesson. 8 | - i found the fts to be spotty (i suspect a selenium issue); they would often fail early in an unreproducable manner. After repeatedly hitting the fts, i would be able to power through. Any word on that? Examples: 9 | 10 | ERROR: test_voting_on_a_new_poll (fts.tests.PollsTest) 11 | ---------------------------------------------------------------------- 12 | Traceback (most recent call last): 13 | File "/home/shannon/work/internal/projects/TDDjangoTutorial/fts/tests.py", line 157, in test_voting_on_a_new_poll 14 | self._setup_polls_via_admin() 15 | File "/home/shannon/work/internal/projects/TDDjangoTutorial/fts/tests.py", line 121, in _setup_polls_via_admin 16 | self.browser.find_element_by_link_text('Add poll').click() 17 | File "/home/shannon/.virtualenvs/tdd/local/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 237, in find_element_by_link_text 18 | return self.find_element(by=By.LINK_TEXT, value=link_text) 19 | File "/home/shannon/.virtualenvs/tdd/local/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 671, in find_element 20 | {'using': by, 'value': value})['value'] 21 | File "/home/shannon/.virtualenvs/tdd/local/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 156, in execute 22 | self.error_handler.check_response(response) 23 | File "/home/shannon/.virtualenvs/tdd/local/lib/python2.7/site-packages/selenium/webdriver/remote/errorhandler.py", line 147, in check_response 24 | raise exception_class(message, screen, stacktrace) 25 | NoSuchElementException: Message: u'Unable to locate element: {"method":"link text","selector":"Add poll"}' 26 | 27 | 28 | ERROR: test_voting_on_a_new_poll (fts.tests.PollsTest) 29 | ---------------------------------------------------------------------- 30 | Traceback (most recent call last): 31 | File "/home/shannon/work/internal/projects/TDDjangoTutorial/fts/tests.py", line 157, in test_voting_on_a_new_poll 32 | self._setup_polls_via_admin() 33 | File "/home/shannon/work/internal/projects/TDDjangoTutorial/fts/tests.py", line 120, in _setup_polls_via_admin 34 | self.browser.find_elements_by_link_text('Polls')[1].click() 35 | IndexError: list index out of range 36 | 37 | 38 | ERROR: test_voting_on_a_new_poll (fts.tests.PollsTest) 39 | ---------------------------------------------------------------------- 40 | Traceback (most recent call last): 41 | File "/home/shannon/work/internal/projects/TDDjangoTutorial/fts/tests.py", line 208, in test_voting_on_a_new_poll 42 | body_text = self.browser.find_element_by_tag_name('body').text 43 | File "/home/shannon/.virtualenvs/tdd/local/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 309, in find_element_by_tag_name 44 | return self.find_element(by=By.TAG_NAME, value=name) 45 | File "/home/shannon/.virtualenvs/tdd/local/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 671, in find_element 46 | {'using': by, 'value': value})['value'] 47 | File "/home/shannon/.virtualenvs/tdd/local/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 156, in execute 48 | self.error_handler.check_response(response) 49 | File "/home/shannon/.virtualenvs/tdd/local/lib/python2.7/site-packages/selenium/webdriver/remote/errorhandler.py", line 147, in check_response 50 | raise exception_class(message, screen, stacktrace) 51 | NoSuchElementException: Message: u'Unable to locate element: {"method":"tag name","selector":"body"}' 52 | 53 | 54 | Part 1 55 | - don't like the "yes, we really meant it" explanation of INSTALLED_APPS. Prefer something like the "enabled" language in https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps 56 | - "It's OK to ignore these for now - we'll deal with templates for 500 errors in a later tutorial." ... i didn't find that later tutorial, but nominate Part 3 57 | 58 | 59 | Part 2 60 | - i don't think the user was instructed to run syncdb for the new Poll model, at 'Now, when you click the link you should see a menu a bit like this.' The message, "Remember, you may need to syncdb..." comes late 61 | - you might want to explain why 'body' is redefined in the test after every click, for a novice user 62 | 63 | 64 | Part 3 65 | - 'ViewDoesNotExist: Could not import polls.views.poll. View does not exist in module polls.views.' - i do not get this error when following the instructions, although view does indeed not exist. 66 | 67 | Part 4 68 | - show import for PollVoteForm before `return render(request, 'poll.html', {'poll': poll, 'form': form})` 69 | 70 | Part 5 71 | - grammar: `# results. it still says # "100 %: very awesome".` Capitalize "It" and "Very" 72 | - i propose a test-driven way of refactoring tests: have them copy the model tests to an empty `test_models.py`, fail when the imports are missing, then add the necessary imports (rather than copy the whole file and remove parts) because the tests can guide that process. This way, you don't end up with unnecessary imports in the resulting modules (like importing a form in the model tests) although it is a bit more tedious. You don't seem to have a beef with tedium, though... 73 | - Part 4 (and 5) says `test_page_shows_choices_using_form` goes in SinglePollViewTest, but it's in the forms tests, according to the repo 74 | - code underneath `Dealing with POST requests in a view` doesn't indicate that there is still (or could be) a `test_page_shows_poll_title_and_no_votes_message` method before `test_page_shows_choices_using_form` 75 | - grammar: `# check it's votes have gone up by 1` : "its" 76 | 77 | -------------------------------------------------------------------------------- /tutorial02.rst: -------------------------------------------------------------------------------- 1 | Welcome to part 2 of the tutorial! Hope you've had a little break, maybe a 2 | `nice chocolate biscuit `_, 3 | and are super-excited to do more! 4 | 5 | Tutorial 2 - Customising the admin site 6 | ======================================= 7 | 8 | Last time we managed to get the admin site up and running, this time it's time 9 | to actualy get it working the way we want it to, so that we can use it to 10 | create new polls for our site. 11 | 12 | Here's an outline of what we're going to do: 13 | 14 | * Create an FT that can create a new poll via the admin site 15 | 16 | * Customise the human-readable display for polls 17 | 18 | * Create "Choice" related model objects to go with polls 19 | 20 | * Add choices to the admin site 21 | 22 | 23 | 24 | Inspecting the admin site to decide what to test next 25 | ----------------------------------------------------- 26 | 27 | Let's fire up the development server, and do a bit of browsing around the admin 28 | site - that way we can figure out what we want the "Polls" bit to look like.:: 29 | 30 | python manage.py runserver 31 | 32 | Then, open your web browser and go to ``http://localhost:8000/admin/``. Login 33 | with the admin username and password (``admin / adm1n``). 34 | 35 | If you go into the Polls section and try and create a new Poll, you need to 36 | click on a link that says "Add Poll" - let's add that to our FT. In 37 | ``fts/tests.py``: 38 | 39 | .. sourcecode:: python 40 | :filename: mysite/fts/tests.py 41 | 42 | # She now sees a couple of hyperlink that says "Polls" 43 | polls_links = self.browser.find_elements_by_link_text('Polls') 44 | self.assertEquals(len(polls_links), 2) 45 | 46 | # The second one looks more exciting, so she clicks it 47 | polls_links[1].click() 48 | 49 | # She is taken to the polls listing page, which shows she has 50 | # no polls yet 51 | body = self.browser.find_element_by_tag_name('body') 52 | self.assertIn('0 polls', body.text) 53 | 54 | # She sees a link to 'add' a new poll, so she clicks it 55 | new_poll_link = self.browser.find_element_by_link_text('Add poll') 56 | new_poll_link.click() 57 | 58 | ``find_element_by_link_text`` is a very useful Selenium function - it's a good 59 | combination of the presentation layer (what the user sees when they click a 60 | link) and the functionality of the site (hyperlink one of the major ways that 61 | users actually interact with a website) 62 | 63 | Now, when you click the link you should see a menu a bit like this. 64 | 65 | .. image:: /static/images/add_poll_need_verbose_name_for_pub_date.png 66 | 67 | Pretty neat, but `Pub date` isn't a very nice label for our publication date 68 | field. Django normally generates labels for its admin fields automatically, by 69 | just taking the field name and capitalising it, converting underscores to 70 | spaces. So that works well for ``question``, but not so well for ``pub_date``. 71 | 72 | So that's one thing we'll want to change. Let's add a test for that to the end 73 | of our FT 74 | 75 | .. sourcecode:: python 76 | :filename: mysite/fts/tests.py 77 | 78 | # She sees some input fields for "Question" and "Date published" 79 | body = self.browser.find_element_by_tag_name('body') 80 | self.assertIn('Question:', body.text) 81 | self.assertIn('Date published:', body.text) 82 | 83 | 84 | Mmmh, "Date Published", much nicer. 85 | 86 | 87 | More ways of finding elements on the page using Selenium 88 | -------------------------------------------------------- 89 | 90 | If you try filling in a new Poll, and fill in the 'date' entry but not a 91 | 'time'. You'll find django complains that the field is required. So, in our 92 | test, we need to fill in three fields: `question`, `date`, and `time`. 93 | 94 | In order to get Selenium to find the text input boxes for those fields, there 95 | are several options:: 96 | 97 | find_element_by_id 98 | find_element_by_xpath 99 | find_element_by_link_text 100 | find_element_by_name 101 | find_element_by_tag_name 102 | find_element_by_css_selector 103 | 104 | And several others - find out more in the selenium documentation (choose Python 105 | as your language for the examples), or just by looking at the source code: 106 | http://seleniumhq.org/docs/03_webdriver.html 107 | http://code.google.com/p/selenium/source/browse/trunk/py/selenium/webdriver/remote/webdriver.py 108 | 109 | In our case `by name` is a useful way of finding fields, because the name 110 | attribute is usually associated with input fields from forms. If you take a 111 | look at the HTML source code for the Django admin page for entering a new poll 112 | (either the raw source, or using a tool like Firebug, or developer tools in 113 | Google Chrome), you'll find out that the 'name' for our three fields are 114 | `question`, `pub_date_0` and `pub_date_1`.: 115 | 116 | 117 | .. sourcecode:: html 118 | :filename: html source for admin site 119 | 120 | 121 | 122 | 123 | 124 |

125 | Date: 126 | 127 |
128 | Time: 129 | 130 |

131 | 132 | 133 | 134 | Let's use them in our FT 135 | 136 | .. sourcecode:: python 137 | :filename: mysite/fts/tests.py 138 | 139 | # She sees some input fields for "Question" and "Date published" 140 | body = self.browser.find_element_by_tag_name('body') 141 | self.assertIn('Question:', body.text) 142 | self.assertIn('Date published:', body.text) 143 | 144 | # She types in an interesting question for the Poll 145 | question_field = self.browser.find_element_by_name('question') 146 | question_field.send_keys("How awesome is Test-Driven Development?") 147 | 148 | # She sets the date and time of publication - it'll be a new year's 149 | # poll! 150 | date_field = self.browser.find_element_by_name('pub_date_0') 151 | date_field.send_keys('01/01/12') 152 | time_field = self.browser.find_element_by_name('pub_date_1') 153 | time_field.send_keys('00:00') 154 | 155 | 156 | We can also use the CSS selector to pick up the "Save" button 157 | 158 | .. sourcecode:: python 159 | :filename: mysite/fts/tests.py 160 | 161 | # Gertrude clicks the save button 162 | save_button = self.browser.find_element_by_css_selector("input[value='Save']") 163 | save_button.click() 164 | 165 | 166 | Then, when you hit 'Save', you'll see that we get taken back to the Polls 167 | listings page. You'll notice that the new poll is just described as "Poll 168 | object". 169 | 170 | .. image:: /static/images/django_admin_poll_object_needs_verbose_name.png 171 | 172 | Django lets you give them more descriptive names, including any attribute of 173 | the object. So let's say we want our polls listed by their question... And 174 | let's call that the end of our FT - you can get rid of the ``self.fail``. 175 | 176 | .. sourcecode:: python 177 | :filename: mysite/fts/tests.py 178 | 179 | # She is returned to the "Polls" listing, where she can see her 180 | # new poll, listed as a clickable link 181 | new_poll_links = self.browser.find_elements_by_link_text( 182 | "How awesome is Test-Driven Development?" 183 | ) 184 | self.assertEquals(len(new_poll_links), 1) 185 | 186 | # Satisfied, she goes back to sleep 187 | 188 | That's it for now - if you've lost track in amongst all the copy & pasting, you 189 | can compare your version to mine, which is hosted here: 190 | 191 | https://github.com/hjwp/Test-Driven-Django-Tutorial/blob/master/mysite/fts/tests.py 192 | 193 | 194 | Human-readable names for models and their attributes 195 | ---------------------------------------------------- 196 | 197 | Let's re-run our tests. Here's our first expected failure, the fact that "Pub 198 | date" isn't the label we want for our field ("Date published"):: 199 | 200 | python manage.py test fts 201 | 202 | ====================================================================== 203 | FAIL: test_can_create_new_poll_via_admin_site (tests.PollsTest) 204 | ---------------------------------------------------------------------- 205 | Traceback (most recent call last): 206 | File "/home/harry/workspace/mysite/fts/tests.py", line 43, in 207 | test_can_create_new_poll_via_admin_site 208 | self.assertIn('Date published:', body.text) 209 | django.kill() #TODO: doesn't kill child processes, fix 210 | AssertionError: 'Date published:' not found in u'Django administration\n 211 | Welcome, admin. Change password / Log out\n 212 | Home \u203a Polls \u203a Polls \u203a Add poll\nAdd poll\nQuestion:\n 213 | Pub date:\nDate: Today | \nTime: Now | ' 214 | 215 | ---------------------------------------------------------------------- 216 | 217 | 218 | Unit testing the verbose name for pub_date 219 | ------------------------------------------ 220 | 221 | Django stores human-readable names for model attributes in a special attribute 222 | called `verbose_name`. Let's write a unit test that checks the verbose name 223 | for our ``pub_date`` field. Add the following method to ``polls/tests.py`` 224 | 225 | .. sourcecode:: python 226 | :filename: mysite/polls/tests.py 227 | 228 | def test_verbose_name_for_pub_date(self): 229 | for field in Poll._meta.fields: 230 | if field.name == 'pub_date': 231 | self.assertEquals(field.verbose_name, 'Date published') 232 | 233 | 234 | To write this test, we have to grovel through the ``_meta`` attribute on the 235 | Poll class. That's some Django-voodoo right there, and you may have to take my 236 | word for it, but it's a way to get at some of the information about the 237 | metadata on the model. There's more info here (James Bennet is one of the 238 | original Django developers, and wrote a book about it too) 239 | http://www.b-list.org/weblog/2007/nov/04/working-models/ 240 | 241 | Anyway, running our tests with ``python manage.py test polls`` gives us our 242 | expected fail:: 243 | 244 | AssertionError: 'pub date' != 'Date published' 245 | 246 | 247 | Now that we have a unit test, we can implement! Let's make a change in 248 | ``models.py`` 249 | 250 | .. sourcecode:: python 251 | :filename: mysite/polls/models.py 252 | 253 | class Poll(models.Model): 254 | question = models.CharField(max_length=200) 255 | pub_date = models.DateTimeField(verbose_name='Date published') 256 | 257 | Run the unit tests again to check that's worked:: 258 | 259 | $ python manage.py test polls 260 | Creating test database for alias 'default'... 261 | .. 262 | ---------------------------------------------------------------------- 263 | Ran 2 tests in 0.001s 264 | 265 | Now, re-running our functional tests, things have moved on:: 266 | 267 | 268 | $ python manage.py test fts 269 | 270 | ====================================================================== 271 | FAIL: test_can_create_new_poll_via_admin_site (tests.PollsTest) 272 | ---------------------------------------------------------------------- 273 | Traceback (most recent call last): 274 | File "/home/harry/workspace/mysite/fts/tests.py", line 63, in 275 | test_can_create_new_poll_via_admin_site 276 | self.assertEquals(len(new_poll_links), 1) 277 | AssertionError: 0 != 1 278 | 279 | ---------------------------------------------------------------------- 280 | 281 | We're almost there - the FT has managed to create and save the new poll, but 282 | when it gets back to the listings page, it can't find a hyperlink whose text is 283 | the new question - it's still listed as an unhelpful "Poll object" 284 | 285 | 286 | To make this work, we need to tell Django how to print out a Poll object. This 287 | happens in the ``__unicode__`` method. As usual, we unit test first, in this 288 | case it's a very simple one - 289 | 290 | .. sourcecode:: python 291 | :filename: mysite/polls/tests.py 292 | 293 | def test_poll_objects_are_named_after_their_question(self): 294 | p = Poll() 295 | p.question = 'How is babby formed?' 296 | self.assertEquals(unicode(p), 'How is babby formed?') 297 | 298 | Running the unit tests shows the following error:: 299 | 300 | ====================================================================== 301 | FAIL: test_poll_objects_are_named_after_their_question (polls.tests.PollModelTest) 302 | ---------------------------------------------------------------------- 303 | Traceback (most recent call last): 304 | File "/home/harry/workspace/mysite/polls/tests.py", line 37, in 305 | test_poll_objects_are_named_after_their_question 306 | self.assertEquals(unicode(p), 'How is babby formed?') 307 | AssertionError: u'Poll object' != 'How is babby formed?' 308 | 309 | ---------------------------------------------------------------------- 310 | 311 | And the fix is simple too - we define a ``__unicode__`` method on our Poll 312 | class, in ``models.py`` 313 | 314 | .. sourcecode:: python 315 | :filename: mysite/polls/models.py 316 | 317 | class Poll(models.Model): 318 | question = models.CharField(max_length=200) 319 | pub_date = models.DateTimeField(verbose_name='Date published') 320 | 321 | def __unicode__(self): 322 | return self.question 323 | 324 | 325 | And you should now find that the unit tests pass:: 326 | 327 | $ python manage.py test polls 328 | Creating test database for alias 'default'... 329 | ... 330 | Ran 3 tests in 0.001s 331 | 332 | 333 | And now, our functional tests should get to the end:: 334 | 335 | AssertionError: todo: finish tests 336 | 337 | 338 | Let's do just that. 339 | 340 | 341 | Adding Choices to the Poll admin page 342 | ===================================== 343 | 344 | Now, our polls currently only have a question - we want to give each poll a set 345 | of possible answers, or "choices", for the user to pick between. Ideally, we 346 | want Gertrude to be able to fill in the choices on the same screen as she 347 | defines the question. Thankfully, Django allows this - you can see it in the 348 | Django tutorial, you can have Choices on the same page as the "Add new Poll" 349 | page. 350 | 351 | https://docs.djangoproject.com/en/1.4/intro/tutorial02/#adding-related-objects 352 | 353 | So let's add that as an intermediate step in our FT, in between where Gertrude 354 | enters the question, and when she hits save. 355 | 356 | .. sourcecode:: python 357 | :filename: mysite/fts/tests.py 358 | 359 | [...] 360 | time_field.send_keys('00:00') 361 | 362 | # She sees she can enter choices for the Poll. She adds three 363 | choice_1 = self.browser.find_element_by_name('choice_set-0-choice') 364 | choice_1.send_keys('Very awesome') 365 | choice_2 = self.browser.find_element_by_name('choice_set-1-choice') 366 | choice_2.send_keys('Quite awesome') 367 | choice_3 = self.browser.find_element_by_name('choice_set-2-choice') 368 | choice_3.send_keys('Moderately awesome') 369 | 370 | # Gertrude clicks the save button 371 | save_button = self.browser.find_element_by_css_selector("input[value='Save']") 372 | [...] 373 | 374 | 375 | For now you'll have to trust me on those ``choice_set-0-choice`` name 376 | attributes! Let's try running our fts again:: 377 | 378 | NoSuchElementException: Message: u'Unable to locate element: {"method":"name","selector":"choice_set-0-choice"}' 379 | 380 | 381 | Relations between models: Polls and Choices 382 | ------------------------------------------- 383 | 384 | Right, naturally the FT can't find the "choice" elements to fill in on the 385 | admin page, because there's no such thing yet! Let's go ahead and create our 386 | "Choice" model then. As usual, we start with some unit tests - in ``polls/tests.py`` 387 | 388 | .. sourcecode:: python 389 | :filename: mysite/polls/tests.py 390 | 391 | class ChoiceModelTest(TestCase): 392 | 393 | def test_creating_some_choices_for_a_poll(self): 394 | # start by creating a new Poll object 395 | poll = Poll() 396 | poll.question="What's up?" 397 | poll.pub_date = timezone.now() 398 | poll.save() 399 | 400 | # now create a Choice object 401 | choice = Choice() 402 | 403 | # link it with our Poll 404 | choice.poll = poll 405 | 406 | # give it some text 407 | choice.choice = "doin' fine..." 408 | 409 | # and let's say it's had some votes 410 | choice.votes = 3 411 | 412 | # save it 413 | choice.save() 414 | 415 | # try retrieving it from the database, using the poll object's reverse 416 | # lookup 417 | poll_choices = poll.choice_set.all() 418 | self.assertEquals(poll_choices.count(), 1) 419 | 420 | # finally, check its attributes have been saved 421 | choice_from_db = poll_choices[0] 422 | self.assertEquals(choice_from_db, choice) 423 | self.assertEquals(choice_from_db.choice, "doin' fine...") 424 | self.assertEquals(choice_from_db.votes, 3) 425 | 426 | Also remember to add the import to the top of the file 427 | 428 | .. sourcecode:: python 429 | :filename: mysite/polls/tests.py 430 | 431 | from polls.models import Choice, Poll 432 | 433 | And we may as well give it something to import too - in ``polls/models.py`` 434 | 435 | .. sourcecode:: python 436 | :filename: mysite/polls/models.py 437 | 438 | class Choice(object): 439 | pass 440 | 441 | And let's do a unit test run:: 442 | 443 | python manage.py test polls 444 | 445 | ====================================================================== 446 | ERROR: test_creating_some_choices_for_a_poll (polls.tests.ChoiceModelTest) 447 | ---------------------------------------------------------------------- 448 | Traceback (most recent call last): 449 | File "/home/harry/workspace/TDDjango/mysite/polls/tests.py", line 62, in test_creating_some_choices_for_a_poll 450 | choice.save() 451 | AttributeError: 'Choice' object has no attribute 'save' 452 | 453 | ---------------------------------------------------------------------- 454 | Ran 4 tests in 0.745s 455 | 456 | FAILED (errors=1) 457 | 458 | No attribute save - let's make our Choice class into a proper Django model:: 459 | 460 | class Choice(models.Model): 461 | pass 462 | 463 | OK, our tests are complaining that the "poll" object has no attribute 464 | ``choice_set``. This is a special attribute that allows you to retrieve all the 465 | related Choice objects for a particular poll, and it gets added by Django 466 | whenever you define a relationship between two models - a foreign key 467 | relationship for example. 468 | 469 | You can see some more examples of creating Polls and related Choices here: 470 | 471 | https://docs.djangoproject.com/en/1.4/intro/tutorial01/#playing-with-the-api 472 | 473 | Let's add that relationship now 474 | 475 | .. sourcecode:: python 476 | :filename: mysite/polls/models.py 477 | 478 | class Choice(models.Model): 479 | poll = models.ForeignKey(Poll) 480 | 481 | Re-running the unit tests, we get:: 482 | 483 | ====================================================================== 484 | ERROR: test_creating_some_choices_for_a_poll (polls.tests.ChoiceModelTest) 485 | ---------------------------------------------------------------------- 486 | Traceback (most recent call last): 487 | File "/home/harry/workspace/TDDjango/mysite/polls/tests.py", line 72, in test_creating_some_choices_for_a_poll 488 | self.assertEquals(choice_from_db.choice, "doin' fine") 489 | AttributeError: 'Choice' object has no attribute 'choice' 490 | 491 | ---------------------------------------------------------------------- 492 | 493 | Let's give Choice a choice... 494 | 495 | .. sourcecode:: python 496 | :filename: mysite/polls/models.py 497 | 498 | class Choice(models.Model): 499 | poll = models.ForeignKey(Poll) 500 | choice = models.CharField(max_length=200) 501 | 502 | Tests again:: 503 | 504 | AttributeError: 'Choice' object has no attribute 'votes' 505 | 506 | Let's add votes 507 | 508 | .. sourcecode:: python 509 | :filename: mysite/polls/models.py 510 | 511 | class Choice(models.Model): 512 | poll = models.ForeignKey(Poll) 513 | choice = models.CharField(max_length=200) 514 | votes = models.IntegerField() 515 | 516 | Another test run?:: 517 | 518 | .... 519 | ---------------------------------------------------------------------- 520 | Ran 4 tests in 0.003s 521 | 522 | OK 523 | 524 | Further customisations of the admin view: related objects inline 525 | ---------------------------------------------------------------- 526 | 527 | Hooray! What's next? Well, one of the great things about TDD is that, once 528 | you've written your tests, you don't really have to keep track of what's next 529 | any more. You can can just run the tests, and they'll tell you what to do. So, 530 | what do the tests want? Let's re-run the FTs:: 531 | 532 | python manage.py test fts 533 | Creating test database for alias 'default'... 534 | E 535 | ====================================================================== 536 | ERROR: test_can_create_new_poll_via_admin_site (fts.tests.PollsTest) 537 | ---------------------------------------------------------------------- 538 | Traceback (most recent call last): 539 | File "/home/harry/workspace/mysite/fts/tests.py", line 71, in test_can_create_new_poll_via_admin_site 540 | choice_1 = self.browser.find_element_by_name('choice_set-0-choice') 541 | File "/usr/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 285, in find_element_by_name 542 | return self.find_element(by=By.NAME, value=name) 543 | File "/usr/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 671, in find_element 544 | {'using': by, 'value': value})['value'] 545 | File "/usr/lib/python2.7/site-packages/selenium/webdriver/remote/webdriver.py", line 156, in execute 546 | self.error_handler.check_response(response) 547 | File "/usr/lib/python2.7/site-packages/selenium/webdriver/remote/errorhandler.py", line 147, in check_response 548 | raise exception_class(message, screen, stacktrace) 549 | NoSuchElementException: Message: u'Unable to locate element: {"method":"name","selector":"choice_set-0-choice"}' 550 | 551 | ---------------------------------------------------------------------- 552 | Ran 1 test in 14.098s 553 | 554 | FAILED (errors=1) 555 | 556 | That's right, the FTs want to be able to add "choices" to a poll in the admin 557 | view. Django has a way. Let's edit ``polls/admin.py``, and do some customising 558 | on the way the Poll admin page works 559 | 560 | .. sourcecode:: python 561 | :filename: mysite/polls/admin.py 562 | 563 | from django.contrib import admin 564 | from polls.models import Choice, Poll 565 | 566 | class ChoiceInline(admin.StackedInline): 567 | model = Choice 568 | extra = 3 569 | 570 | class PollAdmin(admin.ModelAdmin): 571 | inlines = [ChoiceInline] 572 | 573 | admin.site.register(Poll, PollAdmin) 574 | 575 | Django has lots of ways of customising the admin site, and I don't want to 576 | dwell on them for too long - check out the docs for more info: 577 | 578 | https://docs.djangoproject.com/en/1.4/intro/tutorial02/#adding-related-objects 579 | 580 | Let's run the FT again:: 581 | 582 | ====================================================================== 583 | FAIL: test_voting_on_a_new_poll (test_polls.PollsTest) 584 | ---------------------------------------------------------------------- 585 | Traceback (most recent call last): 586 | File "/home/harry/workspace/TDDjango/mysite/fts/test_polls.py", line 48, in test_voting_on_a_new_poll 587 | self._setup_polls_via_admin() 588 | File "/home/harry/workspace/TDDjango/mysite/fts/test_polls.py", line 42, in _setup_polls_via_admin 589 | self.assertEquals(len(new_poll_links), 1) 590 | AssertionError: 0 != 1 591 | 592 | ---------------------------------------------------------------------- 593 | 594 | You may have noticed, during the run, that the form got all grumpy about the 595 | 'votes' field being required (if you don't believe me, why not spin up the test 596 | server using ``manage.py runserver`` and check for yourself? Remember, you may 597 | need to ``syncdb``... Alternatively you can add a ``time.sleep(10)`` to the FT 598 | just before the error, and that will let you see what's happening) 599 | 600 | Let's make 'votes' default to 0, by adding a new test in ``polls/tests.py`` 601 | 602 | .. sourcecode:: python 603 | :filename: mysite/polls/tests.py 604 | 605 | def test_choice_defaults(self): 606 | choice = Choice() 607 | self.assertEquals(choice.votes, 0) 608 | 609 | And run it:: 610 | 611 | python manage.py test polls 612 | [...] 613 | AssertionError: None != 0 614 | 615 | And set the default, in ``polls/models.py`` 616 | 617 | .. sourcecode:: python 618 | :filename: mysite/polls/models.py 619 | 620 | class Choice(models.Model): 621 | poll = models.ForeignKey(Poll) 622 | choice = models.CharField(max_length=200) 623 | votes = models.IntegerField(default=0) 624 | 625 | And re-run our tests:: 626 | 627 | . 628 | ---------------------------------------------------------------------- 629 | Ran 2 tests in 21.043s 630 | 631 | OK 632 | 633 | Hooray! Tune in next week, for when we finally get off the admin site, and 634 | into testing some Django pages we've written ourselves... 635 | 636 | -------------------------------------------------------------------------------- /tutorial03.rst: -------------------------------------------------------------------------------- 1 | Part 3 - A normal web page, using Django views and templates 2 | ============================================================ 3 | 4 | Welcome to part 3 of the tutorial! This week we'll finally get into writing 5 | our own web pages, rather than using the Django Admin site. Here's a summary 6 | of what we'll get up to: 7 | 8 | * Write an FT that views and responds to a Poll 9 | 10 | * Create a url, view and template for our site homepage 11 | 12 | * Use the Django Test Client to write unit tests for the above 13 | 14 | 15 | Let's pick up our FT where we left off - we now have the admin site set up to 16 | add Polls, including Choices. We now want to flesh out what the user sees. 17 | 18 | Writing the FT as comments 19 | -------------------------- 20 | 21 | Let's start by writing out our FT as human-readable comments, which describe 22 | the user's actions, and the expected behaviour of the site 23 | 24 | Create a new test method inside ``fts/tests.py``. 25 | 26 | .. sourcecode:: python 27 | :filename: mysite/fts/tests.py 28 | 29 | def test_can_create_new_poll_via_admin_site(self): 30 | [...] 31 | self.assertEquals(len(new_poll_links), 1) 32 | 33 | # Satisfied, she goes back to sleep 34 | 35 | def test_voting_on_a_new_poll(self): 36 | # First, Gertrude the administrator logs into the admin site and 37 | # creates a couple of new Polls, and their response choices 38 | 39 | # Now, Herbert the regular user goes to the homepage of the site. He 40 | # sees a list of polls. 41 | 42 | # He clicks on the link to the first Poll, which is called 43 | # 'How awesome is test-driven development?' 44 | 45 | # He is taken to a poll 'results' page, which says 46 | # "no-one has voted on this poll yet" 47 | 48 | # He also sees a form, which offers him several choices. 49 | # He decided to select "very awesome" 50 | 51 | # He clicks 'submit' 52 | 53 | # The page refreshes, and he sees that his choice 54 | # has updated the results. they now say 55 | # "100 %: very awesome". 56 | 57 | # The page also says "1 votes" 58 | 59 | # Satisfied, he goes back to sleep 60 | 61 | 62 | Setting up data for the test via the admin site 63 | ----------------------------------------------- 64 | 65 | A nice little test, but that very first comment rather glosses over a lot. 66 | Let's split out the Gertrude bit into its own method, for tidiness, and copy 67 | and paste in some code from the admin test. 68 | 69 | You'll see I've changed things slightly, because in the admin test we entered 70 | just one poll and one set of choices, whereas here we're doing several - so 71 | you'll see there's a little loop, and I'm storing the polls' questions and 72 | choices in a couple of namedtuples. 73 | 74 | (*If you've never seen a namedtuple in Python before, you should definitely 75 | look them up! They're a neat way of specificying a structured data type - more 76 | info here:* 77 | http://stackoverflow.com/questions/2970608/what-are-named-tuples-in-python) 78 | 79 | .. sourcecode:: python 80 | :filename: mysite/fts/tests.py 81 | 82 | from collections import namedtuple 83 | from django.test import LiveServerTestCase 84 | from selenium import webdriver 85 | from selenium.webdriver.common.keys import Keys 86 | 87 | PollInfo = namedtuple('PollInfo', ['question', 'choices']) 88 | POLL1 = PollInfo( 89 | question="How awesome is Test-Driven Development?", 90 | choices=[ 91 | 'Very awesome', 92 | 'Quite awesome', 93 | 'Moderately awesome', 94 | ], 95 | ) 96 | POLL2 = PollInfo( 97 | question="Which workshop treat do you prefer?", 98 | choices=[ 99 | 'Beer', 100 | 'Pizza', 101 | 'The Acquisition of Knowledge', 102 | ], 103 | ) 104 | 105 | 106 | class PollsTest(LiveServerTestCase): 107 | fixtures = ['admin_user.json'] 108 | 109 | def setUp(self): 110 | self.browser = webdriver.Firefox() 111 | self.browser.implicitly_wait(3) 112 | 113 | def tearDown(self): 114 | self.browser.quit() 115 | 116 | def test_can_create_new_poll_via_admin_site(self): 117 | # Gertrude opens her web browser, and goes to the admin page 118 | self.browser.get(self.live_server_url + '/admin/') 119 | 120 | [...] 121 | self.assertEquals(len(new_poll_links), 1) 122 | 123 | # Satisfied, she goes back to sleep 124 | 125 | def _setup_polls_via_admin(self): 126 | # Gertrude logs into the admin site 127 | self.browser.get(self.live_server_url + '/admin/') 128 | username_field = self.browser.find_element_by_name('username') 129 | username_field.send_keys('admin') 130 | password_field = self.browser.find_element_by_name('password') 131 | password_field.send_keys('adm1n') 132 | password_field.send_keys(Keys.RETURN) 133 | 134 | # She has a number of polls to enter. For each one, she: 135 | for poll_info in [POLL1, POLL2]: 136 | # Follows the link to the Polls app, and adds a new Poll 137 | self.browser.find_elements_by_link_text('Polls')[1].click() 138 | self.browser.find_element_by_link_text('Add poll').click() 139 | 140 | # Enters its name, and uses the 'today' and 'now' buttons to set 141 | # the publish date 142 | question_field = self.browser.find_element_by_name('question') 143 | question_field.send_keys(poll_info.question) 144 | self.browser.find_element_by_link_text('Today').click() 145 | self.browser.find_element_by_link_text('Now').click() 146 | 147 | # Sees she can enter choices for the Poll on this same page, 148 | # so she does 149 | for i, choice_text in enumerate(poll_info.choices): 150 | choice_field = self.browser.find_element_by_name('choice_set-%d-choice' % i) 151 | choice_field.send_keys(choice_text) 152 | 153 | # Saves her new poll 154 | save_button = self.browser.find_element_by_css_selector("input[value='Save']") 155 | save_button.click() 156 | 157 | # Is returned to the "Polls" listing, where she can see her 158 | # new poll, listed as a clickable link by its name 159 | new_poll_links = self.browser.find_elements_by_link_text( 160 | poll_info.question 161 | ) 162 | self.assertEquals(len(new_poll_links), 1) 163 | 164 | # She goes back to the root of the admin site 165 | self.browser.get(self.live_server_url + '/admin/') 166 | 167 | # She logs out of the admin site 168 | self.browser.find_element_by_link_text('Log out').click() 169 | 170 | 171 | def test_voting_on_a_new_poll(self): 172 | # First, Gertrude the administrator logs into the admin site and 173 | # creates a couple of new Polls, and their response choices 174 | self._setup_polls_via_admin() 175 | 176 | self.fail('TODO') 177 | # Now, Herbert the regular user goes to the homepage of the site. He 178 | [...] 179 | 180 | 181 | Now, if you try running that test, you should see selenium run through and 182 | enter the two polls, and then exit with the "TODO":: 183 | 184 | ====================================================================== 185 | FAIL: test_voting_on_a_new_poll (tests.TestPolls) 186 | ---------------------------------------------------------------------- 187 | Traceback (most recent call last): 188 | File "/home/harry/workspace/tddjango_site/source/mysite/fts/tests.py", line 76, in test_voting_on_a_new_poll 189 | self.fail('TODO') 190 | AssertionError: TODO 191 | ---------------------------------------------------------------------- 192 | 193 | If it fails any earlier than that, you may not have completed the last couple 194 | of tutorials in quite the same way I specified. Figure out what's wrong! 195 | 196 | 197 | 198 | At last! An FT for a normal page 199 | -------------------------------- 200 | 201 | Let's write the exciting bit of our test, where Herbert the normal user opens 202 | up our website, sees some polls and votes on them. 203 | 204 | 205 | .. sourcecode:: python 206 | :filename: mysite/fts/tests.py 207 | 208 | def test_voting_on_a_new_poll(self): 209 | # First, Gertrude the administrator logs into the admin site and 210 | # creates a couple of new Polls, and their response choices 211 | self._setup_polls_via_admin() 212 | 213 | # Now, Herbert the regular user goes to the homepage of the site. He 214 | # sees a list of polls. 215 | self.browser.get(self.live_server_url) 216 | heading = self.browser.find_element_by_tag_name('h1') 217 | self.assertEquals(heading.text, 'Polls') 218 | 219 | # He clicks on the link to the first Poll, which is called 220 | # 'How awesome is test-driven development?' 221 | first_poll_title = POLL1.question 222 | self.browser.find_element_by_link_text(first_poll_title).click() 223 | 224 | # He is taken to a poll 'results' page, which says 225 | # "no-one has voted on this poll yet" 226 | main_heading = self.browser.find_element_by_tag_name('h1') 227 | self.assertEquals(main_heading.text, 'Poll Results') 228 | sub_heading = self.browser.find_element_by_tag_name('h2') 229 | self.assertEquals(sub_heading.text, first_poll_title) 230 | body = self.browser.find_element_by_tag_name('body') 231 | self.assertIn('No-one has voted on this poll yet', body.text) 232 | 233 | self.fail('TODO') 234 | 235 | # He clicks on the link to the first Poll, which is called 236 | [...] 237 | 238 | 239 | We've started with the first bit, where Herbert goes to the main page of the 240 | site, we check that he can see a Poll there, and that he can click on it. Then 241 | we look for the default 'no votes yet' message on the next page. 242 | 243 | Let's run that, and see where we get:: 244 | 245 | AssertionError: u'Page not found (404)' != 'PollsNoSuchElementException: Message: u'Unable to locate element: {"method":"tag name","selector":"h1"}' 246 | 247 | 248 | URLS and view functions, and the Django Test Client 249 | --------------------------------------------------- 250 | 251 | The FT is telling us that going to the root url (/) doesn't have any ``

`` 252 | elements in - that's because we haven't created it yet! We need to tell Django 253 | what kind of web page to return for the root of our site - the home page if you 254 | like. 255 | 256 | Django uses a file called ``urls.py``, to route visitors to the python function 257 | that will deal with producing a response for them. These functions are called 258 | `views` in Django terminology, and they live in ``views.py``. 259 | 260 | (*This is essentially an MVC pattern, there's some discussion of it here:* 261 | https://docs.djangoproject.com/en/dev/faq/general/#django-appears-to-be-a-mvc-framework-but-you-call-the-controller-the-view-and-the-view-the-template-how-come-you-don-t-use-the-standard-names) 262 | 263 | Let's add a new test to ``polls/tests.py``. I'm going to use the Django Test Client, 264 | which has some helpful features for testing views. More info here: 265 | 266 | https://docs.djangoproject.com/en/1.4/topics/testing/ 267 | 268 | We'll create a new class to test our home page view: 269 | 270 | .. sourcecode:: python 271 | :filename: mysite/polls/tests.py 272 | 273 | [...] 274 | class HomePageViewTest(TestCase): 275 | 276 | def test_root_url_shows_all_polls(self): 277 | # set up some polls 278 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 279 | poll1.save() 280 | poll2 = Poll(question='life, the universe and everything', pub_date=timezone.now()) 281 | poll2.save() 282 | 283 | response = self.client.get('/') 284 | 285 | self.assertIn(poll1.question, response.content) 286 | self.assertIn(poll2.question, response.content) 287 | 288 | 289 | Now, our first run of the tests will probably complain of a with:: 290 | 291 | TemplateDoesNotExist: 404.html 292 | 293 | Django wants us to create a template for 294 | our "404 error" page. We'll come back to that later. For now, let's make the 295 | ``/`` url return a real HTTP response. 296 | 297 | First we'll create a dummy view in ``polls/views.py``: 298 | 299 | .. sourcecode:: python 300 | :filename: mysite/polls/views.py 301 | 302 | def home(request): 303 | pass 304 | 305 | Now let's hook up this view inside ``mysite/urls.py``: 306 | 307 | .. sourcecode:: python 308 | :filename: mysite/mysite/urls.py 309 | 310 | from django.conf.urls import patterns, include, url 311 | from django.contrib import admin 312 | admin.autodiscover() 313 | 314 | urlpatterns = patterns('', 315 | url(r'^$', 'polls.views.home'), 316 | url(r'^admin/', include(admin.site.urls)), 317 | ) 318 | 319 | ``urls.py`` maps urls (specified as regular expressions) to views. I've used 320 | dotted-string notation to specify the name of the view, but you could also use 321 | the actual view, like this: 322 | 323 | .. sourcecode:: python 324 | :filename: mysite/mysite/urls.py 325 | 326 | from polls.views import home 327 | urlpatterns = patterns('', 328 | url(r'^$', home), 329 | url(r'^admin/', include(admin.site.urls)), 330 | ) 331 | 332 | That would have the advantage that it checks that the view exists and imports 333 | OK. It's a personal preference, but the official tutorial uses dot-notation, 334 | so I thought we'd stick with that. Read more here: 335 | 336 | https://docs.djangoproject.com/en/1.4/intro/tutorial03/#design-your-urls 337 | 338 | Re-running our tests should show us a different error:: 339 | 340 | ====================================================================== 341 | ERROR: test_root_url_shows_all_polls (polls.tests.HomePageViewTest) 342 | ---------------------------------------------------------------------- 343 | Traceback (most recent call last): 344 | File "/home/harry/workspace/tddjango_site/source/mysite/polls/tests.py", line 92, in test_root_url_shows_all_polls 345 | response = client.get('/') 346 | File "/usr/lib/pymodules/python2.7/django/test/client.py", line 445, in get 347 | response = super(Client, self).get(path, data=data, **extra) 348 | File "/usr/lib/pymodules/python2.7/django/test/client.py", line 229, in get 349 | return self.request(**r) 350 | File "/usr/lib/pymodules/python2.7/django/core/handlers/base.py", line 129, in get_response 351 | raise ValueError("The view %s.%s didn't return an HttpResponse object." % (callback.__module__, view_name)) 352 | ValueError: The view polls.views.home didn't return an HttpResponse object. 353 | ---------------------------------------------------------------------- 354 | 355 | Let's get the view to return an HttpResponse: 356 | 357 | .. sourcecode:: python 358 | :filename: mysite/polls/views.py 359 | 360 | from django.http import HttpResponse 361 | 362 | def home(request): 363 | return HttpResponse() 364 | 365 | The tests are now more instructive:: 366 | 367 | ====================================================================== 368 | FAIL: test_root_url_shows_all_polls (polls.tests.HomePageViewTest) 369 | ---------------------------------------------------------------------- 370 | Traceback (most recent call last): 371 | File "/home/harry/workspace/tddjango_site/source/mysite/polls/tests.py", line 96, in test_root_url_shows_all_polls 372 | self.assertIn(poll1.question, response.content) 373 | AssertionError: '6 times 7' not found in '' 374 | ---------------------------------------------------------------------- 375 | 376 | The Django Template system 377 | -------------------------- 378 | 379 | So far, we're returning a blank page. Now, to get the tests to pass, it would 380 | be simple enough to just return a response that contained the questions of our 381 | two polls as "raw" text - like this: 382 | 383 | .. sourcecode:: python 384 | :filename: mysite/polls/views.py 385 | 386 | from django.http import HttpResponse 387 | from polls.models import Poll 388 | 389 | def home(request): 390 | content = '' 391 | for poll in Poll.objects.all(): 392 | content += poll.question 393 | 394 | return HttpResponse(content) 395 | 396 | Sure enough, that gets our limited unit tests passing:: 397 | 398 | $ python manage.py test polls 399 | 400 | Creating test database for alias 'default'... 401 | ...... 402 | ---------------------------------------------------------------------- 403 | Ran 6 tests in 0.009s 404 | 405 | OK 406 | Destroying test database for alias 'default'... 407 | 408 | 409 | Now, this probably seems like a slightly artificial situation - for starters, 410 | the two polls' names will just be concatenated together, without even a space 411 | or a carriage return. We can't possibly leave the situation like this for real 412 | users to see! 413 | 414 | But the point of TDD is to be driven by the tests. At each stage, we only 415 | write the code that our tests require, because that makes absolutely sure that 416 | we have tests for all of our code. 417 | 418 | So, rather than anticipate what we might want to put in our HttpResponse, let's 419 | go to the FT now to see what to do next.:: 420 | 421 | python manage.py test fts 422 | ====================================================================== 423 | ERROR: test_voting_on_a_new_poll (tests.TestPolls) 424 | ---------------------------------------------------------------------- 425 | Traceback (most recent call last): 426 | File "/home/harry/workspace/tddjango_site/source/mysite/fts/tests.py", line 57, in test_voting_on_a_new_poll 427 | heading = self.browser.find_element_by_tag_name('h1') 428 | File "/usr/local/lib/python2.7/dist-packages/selenium/webdriver/remote/webdriver.py", line 306, in find_element_by_tag_name 429 | return self.find_element(by=By.TAG_NAME, value=name) 430 | File "/usr/local/lib/python2.7/dist-packages/selenium/webdriver/remote/webdriver.py", line 637, in find_element 431 | {'using': by, 'value': value})['value'] 432 | File "/usr/local/lib/python2.7/dist-packages/selenium/webdriver/remote/webdriver.py", line 153, in execute 433 | self.error_handler.check_response(response) 434 | File "/usr/local/lib/python2.7/dist-packages/selenium/webdriver/remote/errorhandler.py", line 123, in check_response 435 | raise exception_class(message, screen, stacktrace) 436 | NoSuchElementException: Message: u'Unable to locate element: {"method":"tag name","selector":"h1"}' 437 | ---------------------------------------------------------------------- 438 | Ran 2 tests in 29.119s 439 | 440 | 441 | The FT wants an ``h1`` heading tag on the page. Now, again, we could hard-code 442 | this into view (maybe starting with ``content =

Polls

`` before the 443 | ``for`` loop), but at this point it seems sensible to start to use Django's 444 | template system - that will provide a much more natural way to write web pages. 445 | 446 | The Django TestCase lets us check whether a response was rendered using a 447 | template, by using a special method response called ``assertTemplateUsed``, so 448 | let's use that. In ``polls/tests.py``, add an extra check to our view test: 449 | 450 | .. sourcecode:: python 451 | :filename: mysite/polls/tests.py 452 | 453 | class HomePageViewTest(TestCase): 454 | 455 | def test_root_url_shows_links_to_all_polls(self): 456 | # set up some polls 457 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 458 | poll1.save() 459 | poll2 = Poll(question='life, the universe and everything', pub_date=timezone.now()) 460 | poll2.save() 461 | 462 | response = self.client.get('/') 463 | 464 | # check we've used the right template 465 | self.assertTemplateUsed(response, 'home.html') 466 | 467 | # check the poll names appear on the page 468 | self.assertIn(poll1.question, response.content) 469 | self.assertIn(poll2.question, response.content) 470 | 471 | 472 | Testing ``python manage.py test polls``:: 473 | 474 | ====================================================================== 475 | FAIL: test_root_url_shows_all_polls (polls.tests.HomePageViewTest) 476 | ---------------------------------------------------------------------- 477 | Traceback (most recent call last): 478 | File "/home/harry/workspace/tddjango_site/source/mysite/polls/tests.py", line 94, in test_root_url_shows_all_polls 479 | self.assertTemplateUsed(response, 'home.html') 480 | File "/usr/lib/pymodules/python2.7/django/test/testcases.py", line 510, in assertTemplateUsed 481 | self.fail(msg_prefix + "No templates used to render the response") 482 | AssertionError: No templates used to render the response 483 | ---------------------------------------------------------------------- 484 | Ran 6 tests in 0.009s 485 | 486 | So let's now create our template. Templates usually live in a subfolder of each 487 | app:: 488 | 489 | mkdir polls/templates 490 | touch polls/templates/home.html 491 | 492 | That should give us a folder structure like this:: 493 | 494 | . 495 | |-- database.sqlite 496 | |-- fts 497 | | |-- fixtures 498 | | | `-- admin_user.json 499 | | |-- __init__.py 500 | | |-- models.py 501 | | |-- tests.py 502 | | `-- views.py 503 | |-- manage.py 504 | |-- mysite 505 | | |-- __init__.py 506 | | |-- settings.py 507 | | |-- urls.py 508 | | `-- wsgi.py 509 | `-- polls 510 | |-- admin.py 511 | |-- __init__.py 512 | |-- models.py 513 | |-- templates 514 | | `-- home.html 515 | |-- tests.py 516 | `-- views.py 517 | 518 | 519 | Edit ``home.html`` with your favourite editor, 520 | 521 | .. sourcecode:: html+django 522 | :filename: mysite/polls/templates/home.html 523 | 524 | 525 | 526 |

Polls

527 | {% for poll in polls %} 528 |

{{ poll.question }}

529 | {% endfor %} 530 | 531 | 532 | 533 | You'll probably recognise this as being essentially standard HTML, intermixed 534 | with some special django control codes. These are either surrounded with 535 | ``{%`` - ``%}``, for flow control - like a `for` loop in this case, and ``{{`` 536 | - ``}}`` for printing variables. You can find out more about the Django 537 | template language here: 538 | 539 | https://docs.djangoproject.com/en/1.4/topics/templates/ 540 | 541 | Let's rewrite our code to use this template. For this we can use the Django 542 | ``render`` function, which takes the request and the name of the template, back 543 | in ``polls/views.py``: 544 | 545 | .. sourcecode:: python 546 | :filename: mysite/polls/views.py 547 | 548 | from django.shortcuts import render 549 | from polls.models import Poll 550 | 551 | def home(request): 552 | return render(request, 'home.html') 553 | 554 | Our last unit test error was that we weren't using a template - let's see if 555 | this fixes it:: 556 | 557 | ====================================================================== 558 | FAIL: test_root_url_shows_all_polls (polls.tests.HomePageViewTest) 559 | ---------------------------------------------------------------------- 560 | 561 | Traceback (most recent call last): 562 | File "/home/harry/workspace/tddjango_site/source/mysite/polls/tests.py", line 97, in test_root_url_shows_all_polls 563 | self.assertIn(poll1.question, response.content) 564 | AssertionError: '6 times 7' not found in '\n \n

Polls

\n \n \n\n' 565 | ---------------------------------------------------------------------- 566 | 567 | Sure does! Unfortunately, we've lost our Poll questions from the response 568 | content... 569 | 570 | Looking at the template code, you can see that we want to iterate through a 571 | variable called ``polls``. The way we pass this into a template is via a 572 | dictionary called a `context`. The Django test client also lets us check on 573 | what context objects were used in rendering a response, so we can write a test 574 | for that too: 575 | 576 | .. sourcecode:: python 577 | :filename: mysite/polls/tests.py 578 | 579 | response = self.client.get('/') 580 | 581 | # check we've used the right template 582 | self.assertTemplateUsed(response, 'home.html') 583 | 584 | # check we've passed the polls to the template 585 | polls_in_context = response.context['polls'] 586 | self.assertEquals(list(polls_in_context), [poll1, poll2]) 587 | 588 | # check the poll names appear on the page 589 | self.assertIn(poll1.question, response.content) 590 | self.assertIn(poll2.question, response.content) 591 | 592 | 593 | Notice the way we've had to call ``list`` on ``polls_in_context`` - that's 594 | because Django queries return special ``QuerySet`` objects, which, although 595 | they behave like lists, don't quite compare equal like them. 596 | 597 | Now, re-running the tests gives us:: 598 | 599 | ====================================================================== 600 | ERROR: test_root_url_shows_all_polls (polls.tests.HomePageViewTest) 601 | ---------------------------------------------------------------------- 602 | Traceback (most recent call last): 603 | File "/home/harry/workspace/tddjango_site/source/mysite/polls/tests.py", line 97, in test_root_url_shows_all_polls 604 | polls_in_context = response.context['polls'] 605 | File "/usr/lib/pymodules/python2.7/django/template/context.py", line 60, in __getitem__ 606 | raise KeyError(key) 607 | KeyError: 'polls' 608 | ---------------------------------------------------------------------- 609 | Ran 6 610 | 611 | Essentially, we never passed any 'polls' to our template. Let's add them, 612 | but make them empty - again, the idea is to make the minimal change to move 613 | the test forwards: 614 | 615 | .. sourcecode:: python 616 | :filename: mysite/polls/views.py 617 | 618 | def home(request): 619 | context = {'polls': []} 620 | return render(request, 'home.html', context) 621 | 622 | 623 | Now the unit tests say:: 624 | 625 | ====================================================================== 626 | FAIL: test_root_url_shows_all_polls (polls.tests.HomePageViewTest) 627 | ---------------------------------------------------------------------- 628 | Traceback (most recent call last): 629 | File "/home/harry/workspace/tddjango_site/source/mysite/polls/tests.py", line 98, in test_root_url_shows_all_polls 630 | self.assertEquals(list(polls_in_context), [poll1, poll2]) 631 | AssertionError: Lists differ: [] != [, , ] 639 | ---------------------------------------------------------------------- 640 | 641 | 642 | Let's fix our code so the tests pass: 643 | 644 | .. sourcecode:: python 645 | :filename: mysite/polls/views.py 646 | 647 | from django.shortcuts import render 648 | from polls.models import Poll 649 | 650 | def home(request): 651 | context = {'polls': Poll.objects.all()} 652 | return render(request, 'home.html', context) 653 | 654 | Ta-da!:: 655 | 656 | ...... 657 | ---------------------------------------------------------------------- 658 | Ran 6 tests in 0.011s 659 | 660 | OK 661 | 662 | What do the FTs say now?:: 663 | 664 | python manage.py test fts 665 | ====================================================================== 666 | ERROR: test_voting_on_a_new_poll (tests.TestPolls) 667 | ---------------------------------------------------------------------- 668 | Traceback (most recent call last): 669 | File "/home/harry/workspace/tddjango_site/source/mysite/fts/tests.py", line 62, in test_voting_on_a_new_poll 670 | self.browser.find_element_by_link_text('How awesome is Test-Driven Development?').click() 671 | File "/usr/local/lib/python2.7/dist-packages/selenium/webdriver/remote/webdriver.py", line 234, in find_element_by_link_text 672 | return self.find_element(by=By.LINK_TEXT, value=link_text) 673 | File "/usr/local/lib/python2.7/dist-packages/selenium/webdriver/remote/webdriver.py", line 637, in find_element 674 | {'using': by, 'value': value})['value'] 675 | File "/usr/local/lib/python2.7/dist-packages/selenium/webdriver/remote/webdriver.py", line 153, in execute 676 | self.error_handler.check_response(response) 677 | File "/usr/local/lib/python2.7/dist-packages/selenium/webdriver/remote/errorhandler.py", line 123, in check_response 678 | raise exception_class(message, screen, stacktrace) 679 | NoSuchElementException: Message: u'Unable to locate element: {"method":"link text","selector":"How awesome is Test-Driven Development?"}' 680 | ---------------------------------------------------------------------- 681 | 682 | 683 | Testing philosophy: what to test in templates 684 | --------------------------------------------- 685 | 686 | Ah - although our page may contain the name of our Poll, it's not yet a link we 687 | can click. The way we'd fix this is in the ``home.html`` template, by adding an ``\n \n

Polls

\n \n 6 times 7\n \n life, the universe and everything\n \n \n\n' 856 | ---------------------------------------------------------------------- 857 | 858 | 859 | The templates don't include the hyperlinks yet. Let's add them: 860 | 861 | .. sourcecode:: html+django 862 | :filename: mysite/polls/templates/home.html 863 | 864 | 865 | 866 |

Polls

867 | {% for poll in polls %} 868 |

{{ poll.question }}

869 | {% endfor %} 870 | 871 | 872 | 873 | Notice the call to ``{% url %}``, which works almost exactly like ``reverse``. Are our unit tests any happier?:: 874 | 875 | 876 | ====================================================================== 877 | ERROR: test_root_url_shows_links_to_all_polls (polls.tests.HomePageViewTest) 878 | ---------------------------------------------------------------------- 879 | Traceback (most recent call last): 880 | File "/home/harry/workspace/mysite/polls/tests.py", line 99, in test_root_url_shows_links_to_all_polls 881 | response = client.get('/') 882 | File "/usr/local/lib/python2.7/dist-packages/django/test/client.py", line 439, in get 883 | response = super(Client, self).get(path, data=data, **extra) 884 | File "/usr/local/lib/python2.7/dist-packages/django/test/client.py", line 244, in get 885 | return self.request(**r) 886 | File "/usr/local/lib/python2.7/dist-packages/django/core/handlers/base.py", line 111, in get_response 887 | response = callback(request, *callback_args, **callback_kwargs) 888 | File "/home/harry/workspace/mysite/polls/views.py", line 7, in home 889 | return render(request, 'home.html', context) 890 | File "/usr/local/lib/python2.7/dist-packages/django/shortcuts/__init__.py", line 44, in render 891 | return HttpResponse(loader.render_to_string(*args, **kwargs), 892 | File "/usr/local/lib/python2.7/dist-packages/django/template/loader.py", line 176, in render_to_string 893 | return t.render(context_instance) 894 | File "/usr/local/lib/python2.7/dist-packages/django/template/base.py", line 140, in render 895 | return self._render(context) 896 | File "/usr/local/lib/python2.7/dist-packages/django/test/utils.py", line 62, in instrumented_test_render 897 | return self.nodelist.render(context) 898 | File "/usr/local/lib/python2.7/dist-packages/django/template/base.py", line 823, in render 899 | bit = self.render_node(node, context) 900 | File "/usr/local/lib/python2.7/dist-packages/django/template/debug.py", line 74, in render_node 901 | return node.render(context) 902 | File "/usr/local/lib/python2.7/dist-packages/django/template/defaulttags.py", line 185, in render 903 | nodelist.append(node.render(context)) 904 | File "/usr/local/lib/python2.7/dist-packages/django/template/defaulttags.py", line 411, in render 905 | url = reverse(view_name, args=args, kwargs=kwargs, current_app=context.current_app) 906 | File "/usr/local/lib/python2.7/dist-packages/django/core/urlresolvers.py", line 476, in reverse 907 | return iri_to_uri(resolver._reverse_with_prefix(view, prefix, *args, **kwargs)) 908 | File "/usr/local/lib/python2.7/dist-packages/django/core/urlresolvers.py", line 363, in _reverse_with_prefix 909 | possibilities = self.reverse_dict.getlist(lookup_view) 910 | File "/usr/local/lib/python2.7/dist-packages/django/core/urlresolvers.py", line 276, in reverse_dict 911 | self._populate() 912 | File "/usr/local/lib/python2.7/dist-packages/django/core/urlresolvers.py", line 265, in _populate 913 | lookups.appendlist(pattern.callback, (bits, p_pattern, pattern.default_args)) 914 | File "/usr/local/lib/python2.7/dist-packages/django/core/urlresolvers.py", line 216, in callback 915 | self._callback = get_callable(self._callback_str) 916 | File "/usr/local/lib/python2.7/dist-packages/django/utils/functional.py", line 27, in wrapper 917 | result = func(*args) 918 | File "/usr/local/lib/python2.7/dist-packages/django/core/urlresolvers.py", line 101, in get_callable 919 | (lookup_view, mod_name)) 920 | ViewDoesNotExist: Could not import polls.views.poll. View does not exist in module polls.views. 921 | 922 | ---------------------------------------------------------------------- 923 | 924 | Phew. A long traceback, but basically all it's saying is that we need at least 925 | a placeholder for our new "poll" view in ``mysite/views.py``. Let's add that now: 926 | 927 | .. sourcecode:: python 928 | :filename: mysite/mysite/views.py 929 | 930 | def home(request): 931 | context = {'polls': Poll.objects.all()} 932 | return render(request, 'home.html', context) 933 | 934 | 935 | def poll(): 936 | pass 937 | 938 | And run the unit tests again:: 939 | 940 | 21:08 ~/workspace/tddjango_site/source/mysite (master)$ python manage.py test polls 941 | Creating test database for alias 'default'... 942 | ...... 943 | ---------------------------------------------------------------------- 944 | Ran 6 tests in 0.012s 945 | OK 946 | 947 | What about the functional tests?:: 948 | 949 | NoSuchElementException: Message: u'Unable to locate element: {"method":"tag name","selector":"h1"}' 950 | 951 | 952 | Well, they get past the main page, but they fall over when they try to look at 953 | an individual poll. Looks like it's time to start implementing our `poll` view, 954 | which aims to show information about a particular poll... But for this, you'll 955 | have to tune in next week! 956 | 957 | -------------------------------------------------------------------------------- /tutorial04.rst: -------------------------------------------------------------------------------- 1 | Welcome to part 4 of the tutorial! In this part at how we can let users vote 2 | on our poll, in other words, **web forms!**. Hooray. 3 | 4 | Tutorial 4: Using a form 5 | ======================== 6 | 7 | Here's the outline of what we're going to do in this tutorial: 8 | 9 | * extend the FT to show Herbert voting on the poll 10 | 11 | * create a url, view and template to generate pages for individual polls 12 | 13 | * create a Django form to handle choices 14 | 15 | 16 | Extending the FT to vote using radio buttons 17 | -------------------------------------------- 18 | 19 | Let's start by extending our FT, to show Herbert voting on a poll. In 20 | ``fts/tests.py``: 21 | 22 | .. sourcecode:: python 23 | :filename: mysite/fts/tests.py 24 | 25 | [...] 26 | # Now, Herbert the regular user goes to the homepage of the site. He 27 | # sees a list of polls. 28 | self.browser.get(self.live_server_url) 29 | heading = self.browser.find_element_by_tag_name('h1') 30 | self.assertEquals(heading.text, 'Polls') 31 | 32 | # He clicks on the link to the first Poll, which is called 33 | # 'How awesome is test-driven development?' 34 | first_poll_title = 'How awesome is Test-Driven Development?' 35 | self.browser.find_element_by_link_text(first_poll_title).click() 36 | 37 | # He is taken to a poll 'results' page, which says 38 | # "no-one has voted on this poll yet" 39 | main_heading = self.browser.find_element_by_tag_name('h1') 40 | self.assertEquals(main_heading.text, 'Poll Results') 41 | sub_heading = self.browser.find_element_by_tag_name('h2') 42 | self.assertEquals(sub_heading.text, first_poll_title) 43 | body = self.browser.find_element_by_tag_name('body') 44 | self.assertIn('No-one has voted on this poll yet', body.text) 45 | 46 | # He also sees a form, which offers him several choices. 47 | # There are three options with radio buttons 48 | choice_inputs = self.browser.find_elements_by_css_selector( 49 | "input[type='radio']" 50 | ) 51 | self.assertEquals(len(choice_inputs), 3) 52 | 53 | # The buttons have labels to explain them 54 | choice_labels = self.browser.find_elements_by_tag_name('label') 55 | choices_text = [c.text for c in choice_labels] 56 | self.assertEquals(choices_text, [ 57 | 'Very awesome', 58 | 'Quite awesome', 59 | 'Moderately awesome', 60 | ]) 61 | # He decided to select "very awesome", which is answer #1 62 | chosen = self.browser.find_element_by_css_selector( 63 | "input[value='1']" 64 | ) 65 | chosen.click() 66 | 67 | # Herbert clicks 'submit' 68 | self.browser.find_element_by_css_selector( 69 | "input[type='submit']" 70 | ).click() 71 | 72 | # The page refreshes, and he sees that his choice 73 | # has updated the results. they now say 74 | # "100 %: very awesome". 75 | self.fail('TODO') 76 | 77 | # The page also says "1 votes" 78 | 79 | # Satisfied, he goes back to sleep 80 | 81 | 82 | If you run them, you'll find that they are still telling us the individual poll 83 | page isn't working:: 84 | 85 | NoSuchElementException: Message: u'Unable to locate element: {"method":"tag name","selector":"h1"}' 86 | 87 | 88 | That because, currently, our ``poll`` view is just a placeholder function. We 89 | need to make into into a real Django view, which returns information about a 90 | poll. 91 | 92 | Let's work on the unit tests for the ``poll`` view then. Make a new class for 93 | them in ``polls/tests.py``: 94 | 95 | .. sourcecode:: python 96 | :filename: mysite/polls/tests.py 97 | 98 | class SinglePollViewTest(TestCase): 99 | 100 | def test_page_shows_poll_title_and_no_votes_message(self): 101 | # set up two polls, to check the right one is displayed 102 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 103 | poll1.save() 104 | poll2 = Poll(question='life, the universe and everything', pub_date=timezone.now()) 105 | poll2.save() 106 | 107 | response = self.client.get('/poll/%d/' % (poll2.id, )) 108 | 109 | # check we've used the poll template 110 | self.assertTemplateUsed(response, 'poll.html') 111 | 112 | # check we've passed the right poll into the context 113 | self.assertEquals(response.context['poll'], poll2) 114 | 115 | # check the poll's question appears on the page 116 | self.assertIn(poll2.question, response.content) 117 | 118 | # check our 'no votes yet' message appears 119 | self.assertIn('No-one has voted on this poll yet', response.content) 120 | 121 | 122 | Running the tests gives:: 123 | 124 | TypeError: poll() takes no arguments (2 given) 125 | 126 | (*I'm going to be shortening the test outputs from now on. You're a TDD 127 | veteran now, you can handle it! :-)* 128 | 129 | Let's make our view take two arguments: 130 | 131 | .. sourcecode:: python 132 | :filename: mysite/polls/views.py 133 | 134 | def poll(request, poll_id): 135 | pass 136 | 137 | Now we get:: 138 | 139 | ValueError: The view mysite.polls.views.poll didn't return an HttpResponse object. 140 | 141 | Again, a minimal fix: 142 | 143 | .. sourcecode:: python 144 | :filename: mysite/polls/views.py 145 | 146 | def poll(request, poll_id): 147 | return HttpResponse() 148 | 149 | Now we get this error:: 150 | 151 | AssertionError: No templates used to render the response 152 | 153 | 154 | Let's try fixing that - but deliberately using the wrong template (just to 155 | check we are testing it) 156 | 157 | .. sourcecode:: python 158 | :filename: mysite/polls/views.py 159 | 160 | def poll(request, poll_id): 161 | return render(request, 'home.html') 162 | 163 | Good, looks like we are testiing it properly:: 164 | 165 | AssertionError: Template 'poll.html' was not a template used to render the response. Actual template(s) used: home.html 166 | 167 | And changing it to ``poll.html`` gives us:: 168 | 169 | TemplateDoesNotExist: poll.html 170 | 171 | Fine and dandy, let's make one:: 172 | 173 | touch polls/templates/poll.html 174 | 175 | You might argue that an empty file, all 0 bytes of it, is a fairly minimal 176 | template! Still, it seems to satisfy the tests. Now they want us to pass a 177 | ``poll`` variable in the template's context:: 178 | 179 | KeyError: 'poll' 180 | 181 | So let's do that, again, the minimum possible change to satisfy the tests: 182 | 183 | .. sourcecode:: python 184 | :filename: mysite/polls/views.py 185 | 186 | def poll(request, poll_id): 187 | return render(request, 'poll.html', {'poll': None}) 188 | 189 | And the tests get a little further on:: 190 | 191 | AssertionError: None != 192 | 193 | And they even tell us what to do next - pass in the right `Poll` object: 194 | 195 | .. sourcecode:: python 196 | :filename: mysite/polls/views.py 197 | 198 | def poll(request, poll_id): 199 | poll = Poll.objects.get(pk=poll_id) 200 | return render(request, 'poll.html', {'poll': poll}) 201 | 202 | This is the first time we've used the Django API to fetch a single database 203 | object, and ``objects.get`` is the helper function for this - it raises an 204 | error if it can't find the object, or if it finds more than one. The special 205 | keyword argument ``pk`` stands for `primary key`. In this case, Django is using 206 | the default for primary keys, which is an automatically generated integer 207 | ``id`` column. 208 | 209 | That raises the question of what to do if a user types in a url for a poll that 210 | doesn't exist - ``/poll/0/`` for example. We'll come back to this in a later 211 | tutorial. 212 | 213 | In the meantime, what do the tests say:: 214 | 215 | self.assertIn(poll2.question, response.content) 216 | AssertionError: 'life, the universe and everything' not found in '' 217 | 218 | We need to get our template to include the poll's question. Let's make it into 219 | a page heading: 220 | 221 | .. sourcecode:: html+django 222 | :filename: mysite/polls/templates/poll.html 223 | 224 | 225 | 226 |

{{poll.question}}

227 | 228 | 229 | 230 | Now the tests want our 'no polls yet' message:: 231 | 232 | AssertionError: 'No-one has voted on this poll yet' not found in '\n \n

life, the universe and everything

\n \n\n' 233 | 234 | So let's include that: 235 | 236 | .. sourcecode:: html+django 237 | :filename: mysite/polls/templates/home.html 238 | 239 | 240 | 241 | 242 |

{{poll.question}}

243 | 244 |

No-one has voted on this poll yet

245 | 246 | 247 | 248 | 249 | And that's enough to make the unit tests happy:: 250 | 251 | ---------------------------------------------------------------------- 252 | Ran 7 tests in 0.013s 253 | 254 | OK 255 | 256 | Mmmh, `OK`. And doughnuts. Let's see what the FTs think?:: 257 | 258 | NoSuchElementException: Message: u'Unable to locate element: {"method":"tag name","selector":"h1"}' 259 | 260 | Ah, we forgot to include a general heading for the page - the FT is checking 261 | the ``h1`` and ``h2`` headings: 262 | 263 | .. sourcecode:: python 264 | :filename: mysite/fts/tests.py 265 | 266 | main_heading = self.browser.find_element_by_tag_name('h1') 267 | self.assertEquals(main_heading.text, 'Poll Results') 268 | sub_heading = self.browser.find_element_by_tag_name('h2') 269 | self.assertEquals(sub_heading.text, first_poll_title) 270 | 271 | So, in our template, let's add an ``h1`` with "Poll Results" in it: 272 | 273 | .. sourcecode:: html+django 274 | :filename: mysite/polls/templates/home.html 275 | 276 | 277 | 278 |

Poll Results

279 | 280 |

{{poll.question}}

281 | 282 |

No-one has voted on this poll yet

283 | 284 | 285 | 286 | 287 | 288 | Using a Django form for poll choices 289 | ------------------------------------ 290 | 291 | Now what does the FT say?:: 292 | 293 | ====================================================================== 294 | FAIL: test_voting_on_a_new_poll (tests.TestPolls) 295 | ---------------------------------------------------------------------- 296 | Traceback (most recent call last): 297 | File "/home/harry/workspace/mysite/fts/tests.py", line 100, in test_voting_on_a_new_poll 298 | self.assertEquals(len(choice_inputs), 3) 299 | AssertionError: 0 != 3 300 | 301 | ---------------------------------------------------------------------- 302 | 303 | Ah, we need to add the poll Choices as a series of radio inputs. Now the 304 | official Django tutorial shows you how to hard-code them in HTML: 305 | 306 | https://docs.djangoproject.com/en/1.4/intro/tutorial04/ 307 | 308 | But Django can do even better than that - Django's forms system will generate 309 | radio buttons for us, if we can just give it the right incantations. Let's 310 | create a new test in ``polls/tests.py``: 311 | 312 | 313 | .. sourcecode:: python 314 | :filename: mysite/polls/tests.py 315 | 316 | from polls.forms import PollVoteForm 317 | 318 | class PollsVoteFormTest(TestCase): 319 | 320 | def test_form_renders_poll_choices_as_radio_inputs(self): 321 | # set up a poll with a couple of choices 322 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 323 | poll1.save() 324 | choice1 = Choice(poll=poll1, choice='42', votes=0) 325 | choice1.save() 326 | choice2 = Choice(poll=poll1, choice='The Ultimate Answer', votes=0) 327 | choice2.save() 328 | 329 | # set up another poll to make sure we only see the right choices 330 | poll2 = Poll(question='time', pub_date=timezone.now()) 331 | poll2.save() 332 | choice3 = Choice(poll=poll2, choice='PM', votes=0) 333 | choice3.save() 334 | 335 | # build a voting form for poll1 336 | form = PollVoteForm(poll=poll1) 337 | 338 | # check it has a single field called 'vote', which has right choices: 339 | self.assertEquals(form.fields.keys(), ['vote']) 340 | 341 | # choices are tuples in the format (choice_number, choice_text): 342 | self.assertEquals(form.fields['vote'].choices, [ 343 | (choice1.id, choice1.choice), 344 | (choice2.id, choice2.choice), 345 | ]) 346 | 347 | # check it uses radio inputs to render 348 | self.assertIn('input type="radio"', form.as_p()) 349 | 350 | You might prefer to put the import at the top of the file. 351 | 352 | Looking through the code, you can see we instantiate a form, passing it a poll 353 | object. We then examine the form's ``fields`` attribute, find the one called 354 | ``vote`` (this will also be the ``name`` of the HTML input element), and we 355 | check the ``choices`` for that field. 356 | 357 | For the test to even get off the ground, we may as well create something 358 | minimal for it to import! Create a file called ``polls/forms.py``. 359 | 360 | .. sourcecode:: python 361 | :filename: mysite/polls/forms.py 362 | 363 | class PollVoteForm(object): 364 | pass 365 | 366 | And let's start another test/code cycle, woo -:: 367 | 368 | python manage.py test polls 369 | 370 | [...] 371 | form = PollVoteForm(poll=poll) 372 | TypeError: object.__new__() takes no parameters 373 | 374 | We override ``__init__.py`` to change the constructor: 375 | 376 | .. sourcecode:: python 377 | :filename: mysite/polls/forms.py 378 | 379 | class PollVoteForm(object): 380 | def __init__(self, poll): 381 | pass 382 | 383 | ... :: 384 | 385 | self.assertEquals(form.fields.keys(), ['vote']) 386 | AttributeError: 'PollVoteForm' object has no attribute 'fields' 387 | 388 | To give the form a 'fields' attribute, we can make it inherit from a real 389 | Django form class, and call its parent constructor: 390 | 391 | .. sourcecode:: python 392 | :filename: mysite/polls/forms.py 393 | 394 | from django import forms 395 | 396 | class PollVoteForm(forms.Form): 397 | def __init__(self, poll): 398 | forms.Form.__init__(self) 399 | 400 | Now we get:: 401 | 402 | AssertionError: Lists differ: [] != ['vote'] 403 | 404 | Django form fields are defined a bit like model fields - using inline class 405 | attributes. There are various types of fields, in this case we want one that 406 | has `choices` - a ``ChoiceField``. You can find out more about form fields 407 | here: 408 | 409 | https://docs.djangoproject.com/en/1.4/ref/forms/fields/ 410 | 411 | .. sourcecode:: python 412 | :filename: mysite/polls/forms.py 413 | 414 | class PollVoteForm(forms.Form): 415 | vote = forms.ChoiceField() 416 | 417 | def __init__(self, poll): 418 | forms.Form.__init__(self) 419 | 420 | Now we get:: 421 | 422 | AssertionError: Lists differ: [] != [(1, '42'), (2, 'The Ultimate ... 423 | 424 | So now let's set the choices from the ``poll`` we passed into the constructor 425 | (you can read up on choices in Django here 426 | https://docs.djangoproject.com/en/1.4/ref/models/fields/#field-choices) 427 | 428 | .. sourcecode:: python 429 | :filename: mysite/polls/forms.py 430 | 431 | def __init__(self, poll): 432 | forms.Form.__init__(self) 433 | self.fields['vote'].choices = [(c.id, c.choice) for c in poll.choice_set.all()] 434 | 435 | Mmmmmh, list comprehensions... That will now get the test almost to the end - 436 | we can instantiate a form using a poll object, and the form will automatically 437 | generate the choices based on the poll's ``choice_set.all()`` function, which 438 | gets related objects. 439 | 440 | The final test is to make sure we have radio boxes as the HTML input type. 441 | We're using ``as_p()``, a method provided on all Django forms which renders the 442 | form to HTML for us - we can see exactly what the HTML looks like in the next 443 | test output:: 444 | 445 | self.assertIn('input type="radio"', form.as_p()) 446 | AssertionError: 'input type="radio"' not found in u'

' 447 | 448 | Django has defaulted to using a ``select/option`` input form. We can change 449 | this using a `widget`, in this case a ``RadioSelect`` 450 | 451 | .. sourcecode:: python 452 | :filename: mysite/polls/forms.py 453 | 454 | class PollVoteForm(forms.Form): 455 | vote = forms.ChoiceField(widget=forms.RadioSelect()) 456 | 457 | def __init__(self, poll): 458 | forms.Form.__init__(self) 459 | self.fields['vote'].choices = [(c.id, c.choice) for c in poll.choice_set.all()] 460 | 461 | OK so far? Django forms have *fields*, some of which may have *choices*, and 462 | we can choose how the field will be displayed on page using a *widget*. Right. 463 | 464 | And that should get the tests passing! If you're curious to see what the form 465 | HTML actually looks like, why not temporarily put a ``print form.as_p()`` at 466 | the end of the test? Print statements in tests can be very useful for 467 | exploratory programming... You could try ``form.as_table()`` too if you like... 468 | 469 | Right, where where we? Let's do a quick check of the functional tests. 470 | 471 | (*incidentally, are you rather bored of watching the FT run through the admin 472 | test each time? If so, you can temporarily disable it by renaming its test 473 | method from* ``test_can_create_new_poll_via_admin_site`` *to* 474 | ``DONTtest_can_create_new_poll_via_admin_site`` *that's called "Dontifying"... 475 | you do have to be careful not to forget about your dontified tests though!*) 476 | 477 | python manage.py test fts 478 | [...] 479 | AssertionError: 0 != 3 480 | 481 | Ah yes, we still haven't actually *used* the form yet! Let's go back to our 482 | ``SinglePollViewTest``, and a new test that checks we use our form) 483 | 484 | .. sourcecode:: python 485 | :filename: mysite/polls/tests.py 486 | 487 | class SinglePollViewTest(TestCase): 488 | 489 | def test_page_shows_poll_title_and_no_votes_message(self): 490 | [...] 491 | 492 | 493 | def test_page_shows_choices_using_form(self): 494 | # set up a poll with choices 495 | poll1 = Poll(question='time', pub_date=timezone.now()) 496 | poll1.save() 497 | choice1 = Choice(poll=poll1, choice="PM", votes=0) 498 | choice1.save() 499 | choice2 = Choice(poll=poll1, choice="Gardener's", votes=0) 500 | choice2.save() 501 | 502 | response = self.client.get('/poll/%d/' % (poll1.id, )) 503 | 504 | # check we've passed in a form of the right type 505 | self.assertTrue(isinstance(response.context['form'], PollVoteForm)) 506 | 507 | # and check the form is being used in the template, 508 | # by checking for the choice text 509 | self.assertIn(choice1.choice, response.content) 510 | self.assertIn(choice2.choice, response.content) 511 | 512 | 513 | Now the unit tests give us:: 514 | 515 | python manage.py test polls 516 | [...] 517 | KeyError: 'form' 518 | 519 | So back in ``views.py``: 520 | 521 | .. sourcecode:: python 522 | :filename: mysite/polls/views.py 523 | 524 | def poll(request, poll_id): 525 | poll = Poll.objects.get(pk=poll_id) 526 | return render(request, 'poll.html', {'poll': poll, 'form': None}) 527 | 528 | Now:: 529 | 530 | self.assertTrue(isinstance(response.context['form'], PollVoteForm)) 531 | AssertionError: False is not true 532 | 533 | So: 534 | 535 | .. sourcecode:: python 536 | :filename: mysite/polls/views.py 537 | 538 | from polls.forms import PollVoteForm 539 | 540 | [...] 541 | 542 | def poll(request, poll_id): 543 | poll = Poll.objects.get(pk=poll_id) 544 | form = PollVoteForm(poll=poll) 545 | return render(request, 'poll.html', {'poll': poll, 'form': form}) 546 | 547 | And:: 548 | 549 | self.assertIn(choice3.choice, response.content) 550 | AssertionError: 'PM' not found in '\n \n

Poll Results

\n\n

6 times 7

\n

No-one has voted on this poll yet

\n \n\n\n' 551 | 552 | 553 | So, in ``polls/templates/poll.html``: 554 | 555 | .. sourcecode:: html+django 556 | :filename: mysite/polls/templates/home.html 557 | 558 | 559 | 560 |

Poll Results

561 | 562 |

{{poll.question}}

563 | 564 |

No-one has voted on this poll yet

565 | 566 |

Add your vote

567 | {{form.as_p}} 568 | 569 | 570 | 571 | 572 | 573 | And re-running the tests - oh, a surprise!:: 574 | 575 | self.assertIn(choice4.choice, response.content) 576 | AssertionError: "Gardener's" not found in '\n \n

Poll Results

\n \n

time

\n\n

No-one has voted on this poll yet

\n\n

Add your vote

\n

    \n
  • \n
  • \n

\n\n \n \n\n' 577 | 578 | Django has converted an apostrophe (``'``) into an html-compliant ``'`` for 579 | us. I suppose that's my come-uppance for trying to include British in-jokes in 580 | my tutorial. Let's implement a minor hack in our test: 581 | 582 | 583 | .. sourcecode:: python 584 | :filename: mysite/polls/tests.py 585 | 586 | self.assertIn(choice1.choice, response.content.replace(''', "'")) 587 | self.assertIn(choice2.choice, response.content.replace(''', "'")) 588 | 589 | And now we have passination:: 590 | 591 | ........ 592 | ---------------------------------------------------------------------- 593 | Ran 8 tests in 0.016s 594 | 595 | OK 596 | 597 | So let's ask the FTs again!:: 598 | 599 | ====================================================================== 600 | FAIL: test_voting_on_a_new_poll (tests.TestPolls) 601 | ---------------------------------------------------------------------- 602 | Traceback (most recent call last): 603 | File "/home/harry/workspace/tddjango_site/source/mysite/fts/tests.py", line 84, in test_voting_on_a_new_poll 604 | 'Moderately awesome', 605 | AssertionError: Lists differ: [u'Vote:', u'Very awesome', u'... != ['Very awesome', 'Quite awesom... 606 | 607 | First differing element 0: 608 | Vote: 609 | Very awesome 610 | 611 | First list contains 1 additional elements. 612 | First extra element 3: 613 | Moderately awesome 614 | 615 | - [u'Vote:', u'Very awesome', u'Quite awesome', u'Moderately awesome'] 616 | ? ----------- - - 617 | 618 | + ['Very awesome', 'Quite awesome', 'Moderately awesome'] 619 | 620 | ---------------------------------------------------------------------- 621 | 622 | Hm, not quite according to the original plan - our form has auto-generated an 623 | extra label which says "Vote:" above the radio buttons - well, since it doesn't 624 | do any harm, for now maybe it's easiest to just change the FT: 625 | 626 | .. sourcecode:: python 627 | :filename: mysite/fts/tests.py 628 | 629 | # He also sees a form, which offers him several choices. 630 | # There are three options with radio buttons 631 | choice_inputs = self.browser.find_elements_by_css_selector( 632 | "input[type='radio']" 633 | ) 634 | self.assertEquals(len(choice_inputs), 3) 635 | 636 | # The buttons have labels to explain them 637 | choice_labels = choice_inputs = self.browser.find_elements_by_tag_name('label') 638 | choices_text = [c.text for c in choice_labels] 639 | self.assertEquals(choices_text, [ 640 | 'Vote:', # this label is auto-generated for the whole form 641 | 'Very awesome', 642 | 'Quite awesome', 643 | 'Moderately awesome', 644 | ]) 645 | 646 | 647 | The FT should now get a little further:: 648 | 649 | NoSuchElementException: Message: u'Unable to locate element: {"method":"css selector","selector":"input[type=\'submit\']"}' 650 | 651 | There's no submit button on our form! When Django generates a form, it only 652 | gives you the inputs for the fields you've defined, so no submit button (and no 653 | ``
`` tag either for that matter). 654 | 655 | Well, a button is easy enough to add, although it may not do much... In the 656 | template: 657 | 658 | .. sourcecode:: html+django 659 | :filename: mysite/polls/templates/poll.html 660 | 661 | 662 | 663 |

Poll Results

664 | 665 |

{{poll.question}}

666 | 667 |

No-one has voted on this poll yet

668 | 669 |

Add your vote

670 | {{form.as_p}} 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | And now... our tests get to the end!:: 679 | 680 | ====================================================================== 681 | FAIL: test_voting_on_a_new_poll (tests.TestPolls) 682 | ---------------------------------------------------------------------- 683 | Traceback (most recent call last): 684 | File "/home/harry/workspace/tddjango_site/source/mysite/fts/tests.py", line 125, in test_voting_on_a_new_poll 685 | self.fail('TODO') 686 | AssertionError: TODO 687 | ---------------------------------------------------------------------- 688 | 689 | 690 | Tune in next week for when we finish our tests, handle POST requests, and do 691 | super-fun form validation too... 692 | 693 | -------------------------------------------------------------------------------- /tutorial05.rst: -------------------------------------------------------------------------------- 1 | Welcome to part 5 - this week we'll be looking at processing user input from 2 | forms. 3 | 4 | Tutorial 5: Processing form submissions 5 | ======================================= 6 | 7 | Here's the outline of what we're going to do in this tutorial: 8 | 9 | * wire up our vote form so we can submit votes 10 | 11 | * amend our view to also handle POST requests 12 | 13 | * use helper functions on models 14 | 15 | * quite a lot of fiddling with presentational stuff! 16 | 17 | 18 | Finishing the FT 19 | ---------------- 20 | 21 | Let's pick up from the ``TODO`` in our FT, and extend it to include viewing the 22 | effects of submitting a vote on a poll. In ``fts/tests.py``: 23 | 24 | .. sourcecode:: python 25 | :filename: mysite/fts/tests.py 26 | 27 | [...] 28 | 29 | # Herbert clicks 'submit' 30 | self.browser.find_element_by_css_selector( 31 | "input[type='submit']" 32 | ).click() 33 | 34 | # The page refreshes, and he sees that his choice 35 | # has updated the results. they now say 36 | # "100 %: very awesome". 37 | body_text = self.browser.find_element_by_tag_name('body').text 38 | self.assertIn('100 %: Very awesome', body_text) 39 | 40 | # The page also says "1 vote" 41 | self.assertIn('1 vote', body_text) 42 | 43 | # But not "1 votes" -- Herbert is impressed at the attention to detail 44 | self.assertNotIn('1 votes', body_text) 45 | 46 | # Herbert suspects that the website isn't very well protected 47 | # against people submitting multiple votes yet, so he tries 48 | # to do a little astroturfing 49 | self.browser.find_element_by_css_selector("input[value='1']").click() 50 | self.browser.find_element_by_css_selector("input[type='submit']").click() 51 | 52 | # The page refreshes, and he sees that his choice has updated the 53 | # results. it still says # "100 %: very awesome". 54 | body_text = self.browser.find_element_by_tag_name('body').text 55 | self.assertIn('100 %: Very awesome', body_text) 56 | 57 | # But the page now says "2 votes" 58 | self.assertIn('2 votes', body_text) 59 | 60 | # Cackling manically over his l33t haxx0ring skills, he tries 61 | # voting for a different choice 62 | self.browser.find_element_by_css_selector("input[value='2']").click() 63 | self.browser.find_element_by_css_selector("input[type='submit']").click() 64 | 65 | # Now, the percentages update, as well as the votes 66 | body_text = self.browser.find_element_by_tag_name('body').text 67 | self.assertIn('67 %: Very awesome', body_text) 68 | self.assertIn('33 %: Quite awesome', body_text) 69 | self.assertIn('3 votes', body_text) 70 | 71 | # Satisfied, he goes back to sleep 72 | 73 | If you run the FTs, you should see something like this:: 74 | 75 | ====================================================================== 76 | FAIL: test_voting_on_a_new_poll (tests.TestPolls) 77 | ---------------------------------------------------------------------- 78 | Traceback (most recent call last): 79 | File "/home/harry/workspace/tddjango_site/source/mysite/fts/tests.py", line 126, in test_voting_on_a_new_poll 80 | self.assertIn('100 %: Very awesome', body_text) 81 | AssertionError: '100 %: Very awesome' not found in u'Poll Results\nHow awesome is Test-Driven Development?\nNo-one has voted on this poll yet\nAdd your vote\nVote:\nVery awesome\nQuite awesome\nModerately awesome' 82 | 83 | ---------------------------------------------------------------------- 84 | Ran 1 test in 5.510s 85 | 86 | What's happening is that clicking the submit button has no effect - we just 87 | stay on the voting page. So, we'll need to wire up our view so that it deals 88 | with form submission. Let's open up ``polls/tests.py``. We need to find the test 89 | that deals with our view. 90 | 91 | At this point, you might find it's getting a little hard to find your way 92 | around ``polls/tests.py`` - the file is getting a little cluttered. I think it's 93 | time to do some *refactoring*, and move things around a bit. 94 | 95 | 96 | Refactoring the tests 97 | --------------------- 98 | 99 | Refactoring means making changes to your code that have no functional impact - 100 | and you can refactor your test code as well as your production code. The 101 | purpose of refactoring is usually to try and make your code more legible, less 102 | complex, or to make the architecture neater. And the most important thing about 103 | refactoring is: you need to make sure you don't break anything! That's why 104 | having good tests is absolutely essential to trouble-free refactoring 105 | 106 | So, our objective is to separate out our tests.py into separate files for the 107 | view tests, the model tests and the form tests - so that we have a 108 | ``test_views.py`` to match ``views.py``, a ``test_models.py`` to match 109 | ``models.py``, and so on. 110 | 111 | Let's start by running all our unit tests, making sure they all pass, *and 112 | making a note of how many of them there are* - we don't want to lose any in the 113 | process!:: 114 | 115 | $ python manage.py test polls 116 | Creating test database for alias 'default'... 117 | ......... 118 | ---------------------------------------------------------------------- 119 | Ran 9 tests in 0.016s 120 | 121 | OK 122 | 123 | Right, 9 tests. Now, although our objective is to move to spreading our tests 124 | into 3 different files, we're going to take several small steps to get there. 125 | Then, at each stage, we can re-run our tests to make sure everything still 126 | works. 127 | 128 | The way the django test runner works is that it runs all the tests it can find 129 | in each application, in a python module called ``tests``. Currently, that's a 130 | file called ``tests.py``. But we can change it into a subfolder, by doing 131 | this: 132 | 133 | * create a new folder inside ``polls`` called ``tests`` 134 | 135 | * add a ``__init__.py`` file inside the ``tests`` folder, to make it into 136 | an importable Python module 137 | 138 | * move the current ``polls/tests.py`` into the ``polls/tests/`` folder 139 | 140 | * finally, ``import`` all of the tests from ``tests.py`` into the 141 | ``__init__.py`` 142 | 143 | Depending on your operating system, that could look something like this:: 144 | 145 | mkdir polls/tests 146 | mv polls/tests.py polls/tests 147 | touch polls/tests/__init__.py 148 | 149 | Then, edit ``polls/tests/__init__.py``, and add the ``import``: 150 | 151 | .. sourcecode:: python 152 | :filename: mysite/polls/tests/__init__.py 153 | 154 | from polls.tests.tests import * 155 | 156 | Your tree will look something like this:: 157 | 158 | `-- polls 159 | |-- admin.py 160 | |-- forms.py 161 | |-- __init__.py 162 | |-- models.py 163 | |-- templates 164 | | |-- home.html 165 | | `-- poll.html 166 | |-- tests 167 | | |-- __init__.py 168 | | `-- tests.py 169 | `-- views.py 170 | 171 | 172 | 173 | At this point, we should be able to run the tests again. Let's do so, and check 174 | that exactly the same number of them get run:: 175 | 176 | $ python manage.py test polls 177 | Creating test database for alias 'default'... 178 | ......... 179 | ---------------------------------------------------------------------- 180 | Ran 9 tests in 0.033s 181 | 182 | OK 183 | Destroying test database for alias 'default'... 184 | 185 | 186 | Hooray! Now we have our test in a subfolder, we can start moving them out into 187 | different files. Again, we do this step by step. Let's start by moving all 188 | the model tests into a file called ``test_models.py``. You'll need to move the 189 | following classes: 190 | 191 | * ``PollModelTest`` 192 | 193 | * ``ChoiceModelTest`` 194 | 195 | The way I chose to do it was: 196 | 197 | * Make a copy of ``tests.py``, and save it as ``test_models.py`` 198 | 199 | * Delete all lines after line 81 from ``test_models.py``, leaving our two 200 | model tests 201 | 202 | * The, delete lines 8-81 from ``tests.py``, leaving only non-model tests 203 | 204 | * Finally, tidy up a few unused imports 205 | 206 | OK, is the job done? Let's try re-running our tests:: 207 | 208 | $ python manage.py test polls 209 | Creating test database for alias 'default'... 210 | .... 211 | ---------------------------------------------------------------------- 212 | Ran 4 tests in 0.014s 213 | 214 | OK 215 | 216 | Ah, no - only 4 tests. We've lost 5 somewhere. That's because we need to make 217 | sure that we import all tests into the ``tests/__init__.py`` 218 | 219 | .. sourcecode:: python 220 | :filename: mysite/polls/tests/__init__.py 221 | 222 | from mysite.polls.tests.tests import * 223 | from mysite.polls.tests.test_models import * 224 | 225 | And now:: 226 | 227 | $ python manage.py test polls 228 | Creating test database for alias 'default'... 229 | ......... 230 | ---------------------------------------------------------------------- 231 | Ran 9 tests in 0.016s 232 | 233 | OK 234 | 235 | That's better. Small, baby steps, with a quick check at each stage that everything still works... 236 | 237 | Now, if you're anything like I was when I was first introduced to this method, 238 | you'll be screaming out, internally - "Come on! We could easily just do all 239 | this stuff in one go!"... And, maybe that's even true. But then, think back to 240 | those times you've started off on a mission to refactor your code, and you've 241 | just dived straight in. You make a bunch of changes here, and then you move 242 | onto that part there, and then you remember you also wanted to change this 243 | thing back here, and then you just have to copy and paste these bits there, 244 | rename this, and while we're at it we'll just do this and then, oh gosh where 245 | was I again? Pretty soon you find yourself at the bottom of a depth-first 246 | tree, with no idea of how to get back to where you started, and no idea of what 247 | you need to do to get it all working again. 248 | 249 | So think back to all those times, and maybe erring on the side of caution isn't 250 | so bad. Once you get used to it, you'll find you can fly through it! 251 | 252 | Anyways - next, let's do the views tests. Here's the way I did it: 253 | 254 | * Save a copy of ``tests.py`` as ``test_views.py`` 255 | 256 | * Delete ``PollsVoteFormTest`` from ``test_views.py`` 257 | 258 | * Delete ``HomePageViewTest`` and ``SinglePollViewTest`` from ``tests.py`` 259 | 260 | * add ``from mysite.polls.tests.test_views import *`` to ``polls/tests/__init__,py`` 261 | 262 | * tidy up imports 263 | 264 | Re-running the tests, everything looks ok:: 265 | 266 | $ python manage.py test polls 267 | Creating test database for alias 'default'... 268 | ......... 269 | ---------------------------------------------------------------------- 270 | Ran 9 tests in 0.017s 271 | 272 | OK 273 | 274 | And our final step is to rename ``tests.py`` to ``test_forms.py``. We'll need 275 | to change the import too: 276 | 277 | .. sourcecode:: python 278 | :filename: mysite/polls/tests/__init__.py 279 | 280 | from mysite.polls.tests.test_forms import * 281 | from mysite.polls.tests.test_models import * 282 | from mysite.polls.tests.test_views import * 283 | 284 | Re-running the tests should give us 9 tests again, and we end up with 3 much 285 | more manageable, shorter files. Hooray. 286 | 287 | At this stage your polls app should look something like this:: 288 | 289 | `-- polls 290 | |-- __init__.py 291 | |-- admin.py 292 | |-- forms.py 293 | |-- models.py 294 | |-- templates 295 | | |-- home.html 296 | | `-- poll.html 297 | |-- tests 298 | | |-- __init__.py 299 | | |-- test_forms.py 300 | | |-- test_models.py 301 | | `-- test_views.py 302 | `-- views.py 303 | 304 | Pretty neat and tidy! Let's get back to what we were doing... 305 | 306 | 307 | Dealing with POST requests in a view 308 | ------------------------------------ 309 | 310 | The normal pattern in Django is to use the view that renders your form for GET 311 | requests, to also process form submissions via POST. The main reason is that 312 | it makes it easy to show form validation errors to the user... 313 | 314 | The Django Test Client can generate POST requests as easily as GET ones, we 315 | just need to tell it what the data should be. Let's write a new test in 316 | ``polls/tests/test_views.py`` - we can copy a fair bit from the one above it... 317 | 318 | .. sourcecode:: python 319 | :filename: mysite/polls/tests/test_views.py 320 | 321 | 322 | class SinglePollViewTest(TestCase): 323 | 324 | def test_page_shows_choices_using_form(self): 325 | [...] 326 | 327 | def test_view_can_handle_votes_via_POST(self): 328 | # set up a poll with choices 329 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 330 | poll1.save() 331 | choice1 = Choice(poll=poll1, choice='42', votes=1) 332 | choice1.save() 333 | choice2 = Choice(poll=poll1, choice='The Ultimate Answer', votes=3) 334 | choice2.save() 335 | 336 | # set up our POST data - keys and values are strings 337 | post_data = {'vote': str(choice2.id)} 338 | 339 | # make our request to the view 340 | poll_url = '/poll/%d/' % (poll1.id,) 341 | response = self.client.post(poll_url, data=post_data) 342 | 343 | # retrieve the updated choice from the database 344 | choice_in_db = Choice.objects.get(pk=choice2.id) 345 | 346 | # check it's votes have gone up by 1 347 | self.assertEquals(choice_in_db.votes, 4) 348 | 349 | # always redirect after a POST - even if, in this case, we go back 350 | # to the same page. 351 | self.assertRedirects(response, poll_url) 352 | 353 | Right, let's see how it fails, first:: 354 | 355 | ====================================================================== 356 | FAIL: test_view_can_handle_votes_via_POST (mysite.polls.tests.test_views.SinglePollViewTest) 357 | ---------------------------------------------------------------------- 358 | Traceback (most recent call last): 359 | File "/home/harry/workspace/tddjango_site/source/mysite/../mysite/polls/tests/test_views.py", line 98, in test_view_can_handle_votes_via_POST 360 | self.assertEquals(choice_in_db.votes, 4) 361 | AssertionError: 3 != 4 362 | 363 | ---------------------------------------------------------------------- 364 | 365 | So, the first thing to do is increase the "votes" counter on the appropriate 366 | Choice object... Django puts POST data into a special dictionary on the request 367 | object, ``request.POST``, so let's use that - I'm adding three new lines at the 368 | beginning of the view: 369 | 370 | 371 | .. sourcecode:: python 372 | :filename: mysite/polls/views.py 373 | 374 | from polls.models import Choice, Poll 375 | [...] 376 | 377 | def poll(request, poll_id): 378 | choice = Choice.objects.get(id=request.POST['vote']) 379 | choice.votes += 1 380 | choice.save() 381 | 382 | poll = Poll.objects.get(pk=poll_id) 383 | form = PollVoteForm(poll=poll) 384 | return render(request, 'poll.html', {'poll': poll, 'form': form}) 385 | 386 | 387 | Let's see what the tests think:: 388 | 389 | $ ./manage.py test polls 390 | Creating test database for alias 'default'... 391 | .......EEF 392 | ====================================================================== 393 | ERROR: test_page_shows_choices_using_form (polls.tests.test_views.SinglePollViewTest) 394 | ---------------------------------------------------------------------- 395 | Traceback (most recent call last): 396 | File "/home/harry/workspace/mysite/polls/tests/test_views.py", line 76, in test_page_shows_choices_using_form 397 | response = client.get('/poll/%d/' % (poll1.id, )) 398 | File "/usr/local/lib/python2.7/dist-packages/django/test/client.py", line 439, in get 399 | response = super(Client, self).get(path, data=data, **extra) 400 | File "/usr/local/lib/python2.7/dist-packages/django/test/client.py", line 244, in get 401 | return self.request(**r) 402 | File "/usr/local/lib/python2.7/dist-packages/django/core/handlers/base.py", line 111, in get_response 403 | response = callback(request, *callback_args, **callback_kwargs) 404 | File "/home/harry/workspace/mysite/polls/views.py", line 13, in poll 405 | choice = Choice.objects.get(id=request.POST['vote']) 406 | File "/usr/local/lib/python2.7/dist-packages/django/utils/datastructures.py", line 258, in __getitem__ 407 | raise MultiValueDictKeyError("Key %r not found in %r" % (key, self)) 408 | MultiValueDictKeyError: "Key 'vote' not found in " 409 | 410 | ====================================================================== 411 | ERROR: test_page_shows_poll_title_and_no_votes_message (mysite.polls.tests.test_views.SinglePollViewTest) 412 | ---------------------------------------------------------------------- 413 | Traceback (most recent call last): 414 | File "/home/harry/workspace/tddjango_site/source/mysite/../mysite/polls/tests/test_views.py", line 57, in test_page_shows_poll_title_and_no_votes_message 415 | [...] 416 | MultiValueDictKeyError: "Key 'vote' not found in " 417 | 418 | ====================================================================== 419 | ERROR: test_view_can_handle_votes_via_POST (mysite.polls.tests.test_views.SinglePollViewTest) 420 | ---------------------------------------------------------------------- 421 | Traceback (most recent call last): 422 | File "/home/harry/workspace/tddjango_site/source/mysite/../mysite/polls/tests/test_views.py", line 105, in test_view_can_handle_votes_via_POST 423 | self.assertRedirects(response, poll_url) 424 | AssertionError: Response didn't redirect as expected: Response code was 200 (expected 302) 425 | 426 | ---------------------------------------------------------------------- 427 | Ran 9 tests in 0.031s 428 | 429 | Oh dear - although we've got our POST test a little bit further along, we seem 430 | to have broken 2 other tests. You might argue, it was pretty obvious that was 431 | going to happen, because I've introduced code to upvote choices which is 432 | applied for both GET and POST requests - I should have checked whether the 433 | request was a POST or a GET, and used an ``if``. And, in fact, it was pretty 434 | obvious - I was being deliberately stupid, and made that mistake on purpose. 435 | The point was to demonstrate how TDD can save you from your own stupidity, by 436 | telling you immediately when you break anything... Save those brain cells for 437 | the *really* hard problems. 438 | 439 | So, Django tells us whether a request was a GET or a POST inside the ``method`` 440 | attribute. Let's add an ``if``: 441 | 442 | .. sourcecode:: python 443 | :filename: mysite/polls/views.py 444 | 445 | def poll(request, poll_id): 446 | if request.method == 'POST': 447 | choice = Choice.objects.get(id=request.POST['vote']) 448 | choice.votes += 1 449 | choice.save() 450 | 451 | poll = Poll.objects.get(pk=poll_id) 452 | form = PollVoteForm(poll=poll) 453 | return render(request, 'poll.html', {'poll': poll, 'form': form}) 454 | 455 | And testing...:: 456 | 457 | ERROR: test_view_can_handle_votes_via_POST (mysite.polls.tests.test_views.SinglePollViewTest) 458 | AssertionError: Response didn't redirect as expected: Response code was 200 (expected 302) 459 | 460 | 461 | Right, now we need to do our redirect (*Always redirect after a POST* - 462 | http://www.theserverside.com/news/1365146/Redirect-After-Post). Django has a 463 | class called ``HttpResponseRedirect`` for this, which takes a URL. We'll use 464 | the ``reverse`` function from the last tutorial to get the right URL... 465 | 466 | .. sourcecode:: python 467 | :filename: mysite/polls/views.py 468 | 469 | from django.core.urlresolvers import reverse 470 | from django.http import HttpResponseRedirect 471 | [...] 472 | 473 | def poll(request, poll_id): 474 | if request.method == 'POST': 475 | choice = Choice.objects.get(id=request.POST['vote']) 476 | choice.votes += 1 477 | choice.save() 478 | return HttpResponseRedirect(reverse('polls.views.poll', args=[poll_id,])) 479 | 480 | poll = Poll.objects.get(pk=poll_id) 481 | form = PollVoteForm(poll=poll) 482 | return render(request, 'poll.html', {'poll': poll, 'form': form}) 483 | 484 | Lovely! let's see that at work:: 485 | 486 | $ python manage.py test polls 487 | Creating test database for alias 'default'... 488 | ......... 489 | ---------------------------------------------------------------------- 490 | Ran 10 tests in 0.023s 491 | 492 | OK 493 | 494 | Hooray! Let's see if it gets the FT any further:: 495 | 496 | $ python manage.py test fts 497 | [...] 498 | 499 | AssertionError: '100 %: Very awesome' not found in u'Poll Results\nHow awesome is Test-Driven Development?\nNo-one has voted on this poll yet\nAdd your vote\nVote:\nVery awesome\nQuite awesome\nModerately awesome' 500 | 501 | Nope. We still have to get our page to reflect the percentage of votes. Let's 502 | make a quick test in ``test_views``: 503 | 504 | .. sourcecode:: python 505 | :filename: mysite/polls/tests/test_views.py 506 | 507 | def test_view_shows_percentage_of_votes(self): 508 | # set up a poll with choices 509 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 510 | poll1.save() 511 | choice1 = Choice(poll=poll1, choice='42', votes=1) 512 | choice1.save() 513 | choice2 = Choice(poll=poll1, choice='The Ultimate Answer', votes=2) 514 | choice2.save() 515 | 516 | response = self.client.get('/poll/%d/' % (poll1.id, )) 517 | 518 | # check the percentages of votes are shown, sensibly rounded 519 | self.assertIn('33 %: 42', response.content) 520 | self.assertIn('67 %: The Ultimate Answer', response.content) 521 | 522 | # and that the 'no-one has voted' message is gone 523 | self.assertNotIn('No-one has voted', response.content) 524 | 525 | 526 | def test_view_can_handle_votes_via_POST(self): 527 | [...] 528 | 529 | Running it gives:: 530 | 531 | AssertionError: '33 %: 42' not found in '\n \n

Poll Results

\n \n

6 times 7

\n\n

No-one has voted on this poll yet

\n\n

Add your vote

\n

    \n
  • \n
  • \n

\n \n\n \n \n\n' 532 | 533 | 534 | Which is all very well - but, actually, the view (or the template) aren't 535 | really the right place to calculate percentage figures. Let's hang that off 536 | the model, as a custom function instead. This test should make my intentions 537 | clear. In ``polls/tests/test_models.py``: 538 | 539 | .. sourcecode:: python 540 | :filename: mysite/polls/tests/test_models.py 541 | 542 | def test_choice_can_calculate_its_own_percentage_of_votes(self): 543 | poll = Poll(question='who?', pub_date=timezone.now()) 544 | poll.save() 545 | choice1 = Choice(poll=poll,choice='me',votes=2) 546 | choice1.save() 547 | choice2 = Choice(poll=poll,choice='you',votes=1) 548 | choice2.save() 549 | 550 | self.assertEquals(choice1.percentage(), 67) 551 | self.assertEquals(choice2.percentage(), 33) 552 | 553 | Self-explanatory? Let's implement. We should now get a new test error:: 554 | 555 | $ python manage.py test polls 556 | .E........F 557 | AttributeError: 'Choice' object has no attribute 'percentage' 558 | 559 | 560 | Let's give ``Choice`` a percentage function. In ``models.py`` 561 | 562 | .. sourcecode:: python 563 | :filename: mysite/polls/models.py 564 | 565 | 566 | class Choice(models.Model): 567 | poll = models.ForeignKey(Poll) 568 | choice = models.CharField(max_length=200) 569 | votes = models.IntegerField(default=0) 570 | 571 | def percentage(self): 572 | pass 573 | 574 | Re-running the tests:: 575 | 576 | self.assertEquals(choice1.percentage(), 66) 577 | AssertionError: None != 67 578 | 579 | And implementing: 580 | 581 | .. sourcecode:: python 582 | :filename: mysite/polls/models.py 583 | 584 | def percentage(self): 585 | total_votes_on_poll = sum(c.votes for c in self.poll.choice_set.all()) 586 | return 100 * self.votes / total_votes_on_poll 587 | 588 | Ah, not quite:: 589 | 590 | self.assertEquals(choice1.percentage(), 67) 591 | AssertionError: 66 != 67 592 | 593 | Darn that integer division! Let's try this: 594 | 595 | .. sourcecode:: python 596 | :filename: mysite/polls/models.py 597 | 598 | def percentage(self): 599 | total_votes_on_poll = sum(c.votes for c in self.poll.choice_set.all()) 600 | return round(100.0 * self.votes / total_votes_on_poll) 601 | 602 | 603 | That gets down from 2 failing tests to 1 failing test. Now let's use our new 604 | percentage function in our template, ``polls/templates/poll.html`` 605 | 606 | .. sourcecode:: html+django 607 | :filename: mysite/polls/templates/poll.html 608 | 609 | 610 | 611 |

Poll Results

612 | 613 |

{{poll.question}}

614 | 615 |
    616 | {% for choice in poll.choice_set.all %} 617 |
  • {{ choice.percentage }} %: {{ choice.choice }}
  • 618 | {% endfor %} 619 |
620 | 621 |

No-one has voted on this poll yet

622 | 623 |

Add your vote

624 | {{form.as_p}} 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | Let's try re-running our tests now:: 633 | 634 | ........E.F 635 | [...] 636 | TemplateSyntaxError: Caught ZeroDivisionError while rendering: float division by zero 637 | [...] 638 | AssertionError: '33 %: 42' not found in '\n \n

Poll Results

\n \n

6 times 7

\n\n
    \n \n
  • 33.0 %: 42
  • \n \n
  • 67.0 %: The Ultimate Answer
  • \n \n
\n\n

No-one has voted on this poll yet

\n\n

Add your vote

\n

    \n
  • \n
  • \n

\n \n\n \n \n\n' 639 | 640 | FAILED (failures=1, errors=1) 641 | 642 | 643 | Oh no! Bad to worse! Our percentage function really is refusing to make our 644 | lives easy - it's susceptible to zero-division errors, and it's producing 645 | floats rather than nice printable percentages... Let's fix that. (but, again, 646 | notice the way it's the tests picking up all these little bugs for us, rather 647 | than us having to try and anticipate them all in advance, or test all the edge 648 | cases manually...) 649 | 650 | So, let's make our percentage function return a proper, accurate float 651 | representation of the percentage (or as accurate as floating-point arithmetic 652 | will allow), and we'll handle the presentation issues in the template. We'll 653 | also make it handle the 0-case. 654 | 655 | .. sourcecode:: python 656 | :filename: mysite/polls/tests/test_models.py 657 | 658 | def test_choice_can_calculate_its_own_percentage_of_votes(self): 659 | poll = Poll(question='who?', pub_date=timezone.now()) 660 | poll.save() 661 | choice1 = Choice(poll=poll,choice='me',votes=2) 662 | choice1.save() 663 | choice2 = Choice(poll=poll,choice='you',votes=1) 664 | choice2.save() 665 | 666 | self.assertEquals(choice1.percentage(), 100 * 2 / 3.0) 667 | self.assertEquals(choice2.percentage(), 100 * 1 / 3.0) 668 | 669 | # also check 0-votes case 670 | choice1.votes = 0 671 | choice1.save() 672 | choice2.votes = 0 673 | choice2.save() 674 | self.assertEquals(choice1.percentage(), 0) 675 | self.assertEquals(choice2.percentage(), 0) 676 | 677 | Re-run the tests:: 678 | 679 | self.assertEquals(choice1.percentage(), 100 * 2 / 3.0) 680 | AssertionError: 67.0 != 66.66666666666667 681 | 682 | Removing the ``round()``... 683 | 684 | .. sourcecode:: python 685 | :filename: mysite/polls/models.py 686 | 687 | def percentage(self): 688 | total_votes_on_poll = sum(c.votes for c in self.poll.choice_set.all()) 689 | return 100.0 * self.votes / total_votes_on_poll 690 | 691 | 692 | And now we get the 0-case error:: 693 | 694 | return 100.0 * self.votes / sum(c.votes for c in self.poll.choice_set.all()) 695 | ZeroDivisionError: float division by zero 696 | 697 | Which we can fix with a ``try/except`` (*Better to ask for forgiveness than for 698 | permission*) 699 | 700 | .. sourcecode:: python 701 | :filename: mysite/polls/models.py 702 | 703 | def percentage(self): 704 | total_votes_on_poll = sum(c.votes for c in self.poll.choice_set.all()) 705 | try: 706 | return 100.0 * self.votes / total_votes_on_poll 707 | except ZeroDivisionError: 708 | return 0 709 | 710 | 711 | Phew. That takes us down to just one final test error:: 712 | 713 | ..........F 714 | ====================================================================== 715 | FAIL: test_view_shows_percentage_of_votes (mysite.polls.tests.test_views.SinglePollViewTest) 716 | self.assertNotIn('No-one has voted', response.content) 717 | AssertionError: 'No-one has voted' unexpectedly found in '\n \n

Poll Results

\n \n

6 times 7

\n\n
    \n \n
  • 33.3333333333 %: 42
  • \n \n
  • 66.6666666667 %: The Ultimate Answer
  • \n \n
\n\n

No-one has voted on this poll yet

\n\n

Add your vote

\n

    \n
  • \n
  • \n

\n \n\n \n \n\n' 718 | 719 | Now, how are we going to decide on whether to show or hide this "no votes yet" 720 | message? Ideally, we want to be able to ask the Poll object its total number 721 | of votes... That might come in useful elsewhere too... 722 | 723 | Let's hope this test/code cycle is self-explanatory. Start with 724 | ``test_models.py``: 725 | 726 | .. sourcecode:: python 727 | :filename: mysite/polls/tests/test_models.py 728 | 729 | class PollModelTest(TestCase): 730 | [...] 731 | 732 | def test_poll_can_tell_you_its_total_number_of_votes(self): 733 | p = Poll(question='where',pub_date=timezone.now()) 734 | p.save() 735 | c1 = Choice(poll=p,choice='here',votes=0) 736 | c1.save() 737 | c2 = Choice(poll=p,choice='there',votes=0) 738 | c2.save() 739 | 740 | self.assertEquals(p.total_votes(), 0) 741 | 742 | c1.votes = 1000 743 | c1.save() 744 | c2.votes = 22 745 | c2.save() 746 | self.assertEquals(p.total_votes(), 1022) 747 | 748 | tests:: 749 | 750 | AttributeError: 'Poll' object has no attribute 'total_votes' 751 | 752 | ``models.py`` 753 | 754 | .. sourcecode:: python 755 | :filename: mysite/polls/models.py 756 | 757 | class Poll(models.Model): 758 | question = models.CharField(max_length=200) 759 | pub_date = models.DateTimeField(verbose_name='Date published') 760 | 761 | def __unicode__(self): 762 | return self.question 763 | 764 | 765 | def total_votes(self): 766 | pass 767 | 768 | tests:: 769 | 770 | AssertionError: None != 0 771 | 772 | ``models.py`` 773 | 774 | .. sourcecode:: python 775 | :filename: mysite/polls/models.py 776 | 777 | def total_votes(self): 778 | return 0 779 | 780 | (oh yeah, TDD. You love it). Tests:: 781 | 782 | AssertionError: 0 != 1022 783 | 784 | Good. ``models.py`` 785 | 786 | .. sourcecode:: python 787 | :filename: mysite/polls/models.py 788 | 789 | def total_votes(self): 790 | return sum(c.votes for c in self.choice_set.all()) 791 | 792 | And that's a pass. Now, does that ``sum`` remind you of anything. Let's 793 | refactor:: 794 | 795 | 796 | class Choice(models.Model): 797 | poll = models.ForeignKey(Poll) 798 | choice = models.CharField(max_length=200) 799 | votes = models.IntegerField(default=0) 800 | 801 | def percentage(self): 802 | try: 803 | return 100.0 * self.votes / self.poll.total_votes() 804 | except ZeroDivisionError: 805 | return 0 806 | 807 | Re-running the tests, all the right ones still pass. Another one of the reasons 808 | that TDD is so great is that it encourages you to refactor at will - because 809 | your code is well tested, you can always know whether or not your refactor 810 | has gone correctly, or whether anything was broken. 811 | 812 | Let's finally get onto our little message. Back in our template, 813 | ``polls/templates/poll.html``: 814 | 815 | .. sourcecode:: html+django 816 | :filename: mysite/polls/templates/poll.html 817 | 818 | 819 | 820 |

Poll Results

821 | 822 |

{{poll.question}}

823 | 824 |
    825 | {% for choice in poll.choice_set.all %} 826 |
  • {{ choice.percentage }} %: {{ choice.choice }}
  • 827 | {% endfor %} 828 |
829 | 830 | 831 | {% if poll.total_votes == 0 %} 832 |

No-one has voted on this poll yet

833 | {% endif %} 834 | 835 |

Add your vote

836 | {{form.as_p}} 837 | 838 | 839 | 840 | 841 | 842 | 843 | And re-run the tests:: 844 | 845 | ............ 846 | ---------------------------------------------------------------------- 847 | Ran 12 tests in 0.043s 848 | OK 849 | 850 | At last! What about the FT?:: 851 | 852 | ====================================================================== 853 | FAIL: test_voting_on_a_new_poll (tests.TestPolls) 854 | ---------------------------------------------------------------------- 855 | Traceback (most recent call last): 856 | File "/home/harry/workspace/tddjango_site/source/mysite/fts/tests.py", line 126, in test_voting_on_a_new_poll 857 | self.assertIn('100 %: Very awesome', body_text) 858 | AssertionError: '100 %: Very awesome' not found in u'Poll Results\nHow awesome is Test-Driven Development?\n0 %: Very awesome\n0 %: Quite awesome\n0 %: Moderately awesome\nNo-one has voted on this poll yet\nAdd your vote\nVote:\nVery awesome\nQuite awesome\nModerately awesome' 859 | 860 | ---------------------------------------------------------------------- 861 | Ran 1 test in 5.677s 862 | 863 | Hmm, not quite. What is missing? The "submit" button doesn't seem to be 864 | working... Ah! Yes - we haven't actually wired up our form yet. Django's 865 | ``form.as_p()`` function doesn't actually give you a ```` tag - you have 866 | to do that yourself, which gives you the choice over where the form sends its 867 | data. Let's do that, in the template, ``polls/templates/poll.html``: 868 | 869 | .. sourcecode:: html+django 870 | :filename: mysite/polls/templates/poll.html 871 | 872 | 873 | 874 |

Poll Results

875 | 876 |

{{poll.question}}

877 | 878 |
    879 | {% for choice in poll.choice_set.all %} 880 |
  • {{ choice.percentage }} %: {{ choice.choice }}
  • 881 | {% endfor %} 882 |
883 | 884 | 885 | {% if poll.total_votes == 0 %} 886 |

No-one has voted on this poll yet

887 | {% endif %} 888 | 889 |

Add your vote

890 | 891 | {{form.as_p}} 892 | 893 | 894 | 895 | 896 | 897 | 898 | 899 | Re-running the FT, we get:: 900 | 901 | AssertionError: '100 %: Very awesome' not found in u'Forbidden (403)\nCSRF verification failed. Request aborted.\nMore information is available with DEBUG=True.' 902 | 903 | Pretty helpful, as error messages go. Let's add an amazing Django voodoo CSRF 904 | tag: 905 | 906 | .. sourcecode:: html+django 907 | :filename: mysite/polls/templates/poll.html 908 | 909 |
910 | {% csrf_token %} 911 | {{form.as_p}} 912 | 913 |
914 | 915 | And now?:: 916 | 917 | AssertionError: '100 %: Very awesome' not found in u'Poll Results\nHow awesome is Test-Driven Development?\n100.0 %: Very awesome\n0.0 %: Quite awesome\n0.0 %: Moderately awesome\nAdd your vote\nVote:\nVery awesome\nQuite awesome\nModerately awesome' 918 | 919 | Still not quite, arg! Just a tiny formatting error though. We can fix this 920 | using one of Django's built-in template filters: 921 | 922 | https://docs.djangoproject.com/en/1.4/ref/templates/builtins/ 923 | 924 | .. sourcecode:: html+django 925 | :filename: mysite/polls/templates/poll.html 926 | 927 |
    928 | {% for choice in poll.choice_set.all %} 929 |
  • {{ choice.percentage|floatformat }} %: {{ choice.choice }}
  • 930 | {% endfor %} 931 |
932 | 933 | 934 | Now what?:: 935 | 936 | FAIL: test_voting_on_a_new_poll (tests.TestPolls) 937 | AssertionError: '1 vote' not found in u'Poll Results\nHow awesome is Test-Driven Development?\n100 %: Very awesome\n0 %: Quite awesome\n0 %: Moderately awesome\nAdd your vote\nVote:\nVery awesome\nQuite awesome\nModerately awesome' 938 | 939 | Aha, looks like that ``total_votes`` function is going to come in useful again! 940 | 941 | Let's add a tiny test to our ``test_views.py``: 942 | 943 | .. sourcecode:: python 944 | :filename: mysite/polls/tests/test_views.py 945 | 946 | def test_view_shows_total_votes(self): 947 | # set up a poll with choices 948 | poll1 = Poll(question='6 times 7', pub_date=timezone.now()) 949 | poll1.save() 950 | choice1 = Choice(poll=poll1, choice='42', votes=1) 951 | choice1.save() 952 | choice2 = Choice(poll=poll1, choice='The Ultimate Answer', votes=2) 953 | choice2.save() 954 | 955 | response = self.client.get('/poll/%d/' % (poll1.id, )) 956 | self.assertIn('3 votes', response.content) 957 | 958 | # also check we only pluralise "votes" if necessary. details! 959 | choice2.votes = 0 960 | choice2.save() 961 | response = self.client.get('/poll/%d/' % (poll1.id, )) 962 | self.assertIn('1 vote', response.content) 963 | self.assertNotIn('1 votes', response.content) 964 | 965 | 966 | Running those tests:: 967 | 968 | FAIL: test_view_shows_percentage_of_votes_and_total_votes (mysite.polls.tests.test_views.SinglePollViewTest) 969 | AssertionError: '33 %: 42' not found in '\n \n

Poll Results

\n \n

6 times 7

\n\n
    \n \n
  • 33.3 %: 42
  • \n \n
  • 66.7 %: The Ultimate Answer
  • \n \n
\n\n\n \n\n

Add your vote

\n
\n
\n

    \n
  • \n
  • \n

\n \n
\n\n \n \n\n' 970 | 971 | FAIL: test_view_shows_total_votes (mysite.polls.tests.test_views.SinglePollViewTest) 972 | AssertionError: '3 votes' not found in '\n \n

Poll Results

\n \n

6 times 7

\n\n
    \n \n
  • 33.3 %: 42
  • \n \n
  • 66.7 %: The Ultimate Answer
  • \n \n
\n\n\n \n\n

Add your vote

\n
\n
\n

    \n
  • \n
  • \n

\n \n
\n\n \n \n\n' 973 | 974 | 975 | Ah, aside from our expected failure, it looks like we also have a minor 976 | regression. Getting this presentational stuff right is fiddly! Still, the fix 977 | isn't too difficult, back in our template, let's tweak the ``floatformat``, and 978 | also add in the ``total_votes``: 979 | 980 | .. sourcecode:: html+django 981 | :filename: mysite/polls/templates/poll.html 982 | 983 | 984 | 985 |

Poll Results

986 | 987 |

{{poll.question}}

988 | 989 |
    990 | {% for choice in poll.choice_set.all %} 991 |
  • {{ choice.percentage|floatformat:0 }} %: {{ choice.choice }}
  • 992 | {% endfor %} 993 |
994 | 995 | 996 | {% if poll.total_votes != 0 %} 997 |

{{ poll.total_votes }} votes

998 | {% else %} 999 |

No-one has voted on this poll yet

1000 | {% endif %} 1001 | 1002 |

Add your vote

1003 |
1004 | {% csrf_token %} 1005 | {{form.as_p}} 1006 | 1007 |
1008 | 1009 | 1010 | 1011 | 1012 | 1013 | Another unit test run:: 1014 | 1015 | AssertionError: '1 votes' unexpectedly found in '\n \n

Poll Results

\n

6 times 7

\n
    \n \n
  • 100 %: 42
  • \n \n
  • 0 %: The Ultimate Answer
  • \n \n
\n\n \n

1 votes

\n \n\n

Add your vote

\n
\n
\n

    \n
  • \n
  • \n

\n \n
\n\n\n \n\n\n' 1016 | 1017 | Ah yes, we want it to say "1 vote", not "1 votes". Django's template system 1018 | has a helpful ``pluralize`` function for this: 1019 | 1020 | .. sourcecode:: html+django 1021 | :filename: mysite/polls/templates/poll.html 1022 | 1023 |

{{ poll.total_votes }} vote{{ poll.total_votes|pluralize }}

1024 | 1025 | Unit tests snow pass:: 1026 | 1027 | $ python manage.py test polls 1028 | Creating test database for alias 'default'... 1029 | ............. 1030 | ---------------------------------------------------------------------- 1031 | Ran 13 tests in 0.061s 1032 | 1033 | Now, how about those functional tests?:: 1034 | 1035 | $ python manage.py test fts 1036 | 1037 | AssertionError: TODO 1038 | 1039 | 1040 | That looks good. How about our fts?:: 1041 | 1042 | $ python manage.py test fts 1043 | Ran 2 tests in 9.606s 1044 | OK 1045 | 1046 | 1047 | Well, that feels like a nice place to break until next time. See you soon! 1048 | 1049 | -------------------------------------------------------------------------------- /tutorial06.rst: -------------------------------------------------------------------------------- 1 | 2 | Part 6 - The Book! 3 | ================== 4 | 5 | *[update April 2012] : The book is now coming out on O'Reilly! more info here:* 6 | 7 | http://www.tdd-django-tutorial.com/blog/articles/2013/test-driven-development-web-applications-book-exis/ 8 | 9 | Thanks so much for following on this far! I'm afraid that's all there is for 10 | now, but I am about to start on the next stage -- a proper book on TDD for 11 | web apps. 12 | 13 | If you've found the tutorial useful so far, I wonder whether I can solicit 14 | some feeback regarding a book version? 15 | 16 | Here's the chapter outline I've got so far. Now, remember, this is a very rough draft, 17 | and it's very much bound to change, but I'd love to hear your thoughts... Especially 18 | about some quite general questions, like: 19 | 20 | * Am I broadly covering the right sort of stuff? 21 | * Am I right to spend plenty of time talking about JavaScript, as well as Python? 22 | * What should I choose as my example app? I've written the outline as if it 23 | were a forums app, but I'm starting to see the appeal of one of the other classic 24 | examples, the "to-do list" (very simple at base, can be extended easily, opportunities 25 | for sharing/social bits, plenty of stuff to do on the client-side too...). What 26 | would you choose as an example? 27 | 28 | 29 | =============================================== 30 | PART 1 - Beginning web app development with TDD 31 | =============================================== 32 | 33 | The idea is to dive straight in with a practical example, rather than talking 34 | a lot of theory up-front. I had originally thought to start with a toy example 35 | (cf these 3 abandoned chapters: http://www.tdd-django-tutorial.com/tutorial/6/ ), 36 | but I decided that people prefer real practical stuff to toy examples... 37 | 38 | I also want the first few chapters to be very short + bit-sized, so that the 39 | reader feels like they're really making progress quickly... (the inspiration 40 | comes from Kent Beck's TDD by Example, an awesome book) 41 | 42 | Essentially part 1 is a re-hash of this online tutorial, using a differnt example 43 | app... 44 | 45 | 46 | 1: Our first functional test with Selenium 47 | ------------------------------------------ 48 | 49 | 50 | * Briefly discuss difference between functional testing (AKA acceptance 51 | testing, integration testing, whatever) and unit testing 52 | * Write first test - Introduce Selenium, `setUp`, `tearDown` 53 | * Demonstrate we can get it to open a web browser, and navigate to a web page 54 | eg - google.com 55 | 56 | 57 | 2: Getting Django set-up and running 58 | ------------------------------------ 59 | 60 | * Change our test to look for the test server 61 | * Switch to Django LiveServerTestCase. Explain 62 | * Get the first test running and failing for a sensible reason 63 | * Create django project `django-admin.py startproject` 64 | * It worked! 65 | 66 | 67 | 68 | 3: A static front page 69 | ---------------------- 70 | 71 | * Look for "Welcome to the Forums", or similar 72 | * `urls.py`, `direct_to_template` ? 73 | 74 | 75 | 76 | 4: Super-users and the Django admin site 77 | ---------------------------------------- 78 | 79 | * Extend FT to try and log in 80 | * Explain the admin site 81 | * Database setup, `settings.py`, `syncdb`, `admin.py` 82 | * `runserver` to show login code 83 | * Explain difference between test database and real database 84 | * Fixtures 85 | 86 | 87 | 88 | 5: First unit tests and Database model 89 | -------------------------------------- 90 | 91 | * Distinction between unit tests and functional tests 92 | * Extend FT to try and create a new topic 93 | * new app 94 | * `models.py` 95 | * test/code cycle 96 | 97 | 98 | 99 | 6: Testing a view 100 | ----------------- 101 | 102 | * urls.py again 103 | * Test view as a function 104 | * assert on string contents 105 | 106 | 107 | 7: Django's template system 108 | ---------------------------- 109 | 110 | * Introduce template syntax 111 | * Keep testing as a function 112 | * The, introduce the Django Test Client 113 | 114 | 115 | 116 | 8: Reflections: what to test, what not to test 117 | ----------------------------------------------- 118 | 119 | * time for a bit of theory/philosophy 120 | * "Don't test constants" 121 | * Test logic 122 | * Tests for simple stuff should be simple, so not much effort 123 | 124 | 125 | 9: Simple Forms 126 | ---------------- 127 | 128 | * Manually coded HTML 129 | * Refactor test classes 130 | 131 | 132 | 10: User Authentication 133 | ----------------------- 134 | 135 | * Sign up, login/logout 136 | * Email? 137 | 138 | 139 | 11: More advanced forms 140 | ----------------------- 141 | 142 | * Use Django Forms classes 143 | 144 | 145 | 146 | 12: On Refactoring 147 | ------------------ 148 | 149 | * Martin Fowler 150 | * Tests critical 151 | * Methodical process - explain step by step 152 | 153 | 154 | 155 | 13: Pagination 156 | -------------- 157 | 158 | * Extend various old unit tests and FTs 159 | 160 | 161 | 162 | ====================================================== 163 | PART 2: More advanced testing for a more advanced site 164 | ====================================================== 165 | 166 | 14: Notifications 167 | ------------------------------ 168 | 169 | * Django Notifications, for post edits 170 | 171 | 172 | 15: Adding style with MarkDown 173 | ------------------------------ 174 | 175 | * Using an external library 176 | 177 | 178 | 16: Switching to OAuth: Mocking 179 | ------------------------------- 180 | 181 | * "Don't store passwords" 182 | * Discuss challenges of external dependencies 183 | 184 | 185 | 17: Getting Dynamic: Testing Javascript part 1 186 | ---------------------------------------------- 187 | 188 | * Simple input validation 189 | * Choose JS unit testing framework (probably Qunit, or YUI) 190 | 191 | 192 | 193 | 18: Testing Javascript part 2 - Ajax 194 | ------------------------------------ 195 | 196 | * Dynamic previews of post input 197 | 198 | 199 | 19: Getting pretty: Bootstrap 200 | ----------------------------- 201 | 202 | * Bring in nicer UI elements 203 | 204 | 205 | 20: Getting pretty: Gravatar 206 | ---------------------------- 207 | 208 | * pictures for users 209 | 210 | 211 | 212 | ============================== 213 | PART 3: Getting seriously sexy 214 | ============================== 215 | 216 | 21: Getting serious about the client-side + single-page website? 217 | ---------------------------------------------------------------- 218 | 219 | * Introduce one of the client-side js frameworks -- backbone.js / ember.js / angular 220 | 221 | 222 | 22: Switching Databases 1: PostgreSQL 223 | ---------------------------------------------- 224 | 225 | * show how Django makes this easy 226 | 227 | 228 | 229 | 23: Websockets and Async on the server-side 230 | ------------------------------------------- 231 | 232 | * we want dynamic notifications of when new posts appear on a thread we're 233 | looking at 234 | * Need to spin up, Tornado/Twisted/Gevent as well as Django LiveServerTestCase 235 | * FT opens multiple browser tabs in parallel 236 | * Big change! 237 | 238 | 239 | 24: Switching Databases 2: NoSQL and MongoDB 240 | ---------------------------------------------- 241 | 242 | * obligatory discussion of NoSQL and MongoDB 243 | * descrine installation, particularities of testing 244 | 245 | 246 | 26: Continuous Integration 247 | -------------------------- 248 | 249 | * Need to build 3 server types 250 | * Jenkins (or maybe buildbot) 251 | * Need to adapt Fts, maybe rely less on LiveServerTestCase 252 | 253 | 254 | 255 | 27: Caching for screamingly fast performance 256 | -------------------------------------------- 257 | 258 | * unit testing `memcached` 259 | * Functionally testing performance 260 | * Apache `ab` testing 261 | 262 | 263 | 264 | 265 | Well, that's what I have so far. What do you think? Have I missed anything 266 | out? Does anything seem superfluous? Most importantly, would you buy it? 267 | 268 | -------------------------------------------------------------------------------- /workshop.rst: -------------------------------------------------------------------------------- 1 | WELCOME TO THE TEST-DRIVEN-DJANGO WORKSHOP 2 | ========================================== 3 | 4 | Required installations 5 | ---------------------- 6 | 7 | - Python (2.7 if poss, 2.6 otherwise) 8 | - Git 9 | - Firefox 10 | - easy_install (aka "setuptools" - ``apt-get install python-setuptools``) 11 | - Django (``easy_install django``) 12 | - Selenium (``easy_install selenium``) 13 | - unittest2 # if on Python 2.6 (``easy_install unittest2``) 14 | 15 | Checkout 16 | -------- 17 | 18 | checkout the base repo:: 19 | 20 | git clone https://github.com/hjwp/Test-Driven-Django-Tutorial tddworkshop 21 | cd tddworkshop 22 | git checkout workshop_part1 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Introduction 34 | ============ 35 | 36 | Who I am, and why should you listen to me? 37 | ------------------------------------------ 38 | 39 | - recent convert, resolver, etc 40 | 41 | 42 | Who knows what? 43 | --------------- 44 | 45 | - Python - anyone v. new to it? 46 | 47 | - Django - anyone never used it? 48 | 49 | - TDD - unittest 50 | 51 | - Selenium 52 | 53 | 54 | Laptops, tools and working 55 | -------------------------- 56 | 57 | - who is on Windows? Mac? Linux? VM? headless?? (the last is bad) 58 | 59 | - who is using an IDE? 60 | 61 | 62 | 63 | 64 | 65 | The Plan 66 | -------- 67 | 68 | - To run through contents of official Django tutorial 69 | - ie, a polls/voting app 70 | - but TDD all the way 71 | 72 | - PART 1: Basic setup & the Django admin site 73 | - first selenium FT 74 | - first unit tests 75 | - models.py, admin.py 76 | 77 | - PART 2: the site home page 78 | - the Django Test Client 79 | - views.py 80 | - templates 81 | 82 | - PART 3: (if time) 83 | - POST request 84 | - refactoring 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | How we will work 98 | ---------------- 99 | 100 | - I will provide various bits of code to build on 101 | - via the git repo 102 | 103 | - We'll do the first two stages together - I code, you code 104 | - parts 3 and 4 will be more free-form 105 | 106 | - the approach: full TDD: 107 | - no code before tests 108 | - FTs first - Selenium 109 | - then unit tests - unittest & Django Test Client 110 | 111 | - We go at the speed of the slowest person 112 | - Ask questions 113 | - when I say "does everyone get that", don't just nod! 114 | - my glamorous assistant will keep a track of the current source tree on 115 | flipchart 116 | - JB & I will come round from time to time and take a look 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | PART 1: 130 | ======= 131 | 132 | CHECKOUT 133 | -------- 134 | 135 | checkout the base repo:: 136 | 137 | git clone https://github.com/hjwp/Test-Driven-Django-Tutorial tddworkshop 138 | cd tddworkshop 139 | git checkout workshop_part1 140 | 141 | 142 | Now we follow ``tutorial01.rst`` 143 | 144 | Some notes: 145 | 146 | - notes for windows users: 147 | - ``https`` checkout for github 148 | - ``move`` not ``mv``. 149 | - ``django-admin.py startproject mysite`` (note extra .py) 150 | - ``python manage.py runserver 8001`` 151 | 152 | 153 | Extra practice: 154 | 155 | - try to use ``element.click()`` to click submit button instead of pressing 156 | Enter. May need to use ``find_element_by_css_selector``. 157 | 158 | - Could you use ``Poll.objects.get`` instead of ``Poll.objects.all`` in the 159 | unit test? What would it change? 160 | 161 | - Can you test max_length on CharFields? (ask for hints!) 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | PART 2: 179 | ======= 180 | 181 | Checkout next part:: 182 | 183 | git stash 184 | git checkout workshop_part3 185 | 186 | Now we follow ``tutorial03.rst`` , starting from the section called 187 | **At last! An FT for a normal page** (circa line 175) 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | --------------------------------------------------------------------------------