├── .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 |
20 |
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