Created
April 22, 2014 08:44
-
-
Save hjwp/11170506 to your computer and use it in GitHub Desktop.
updates for python 3.4 and rewrite chap. 19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
diff --git a/chapter_01.asciidoc b/chapter_01.asciidoc | |
index bee486f..f46cd90 100644 | |
--- a/chapter_01.asciidoc | |
+++ b/chapter_01.asciidoc | |
@@ -265,25 +265,24 @@ Next we can add the rest of the contents of the current folder, `.`: | |
---- | |
$ *git add .* | |
$ *git status* | |
-# On branch master | |
-# | |
-# Initial commit | |
-# | |
-# Changes to be committed: | |
-# (use "git rm --cached <file>..." to unstage) | |
-# | |
-# new file: .gitignore | |
-# new file: functional_tests.py | |
-# new file: manage.py | |
-# new file: superlists/__init__.py | |
-# new file: superlists/__pycache__/__init__.cpython-33.pyc | |
-# new file: superlists/__pycache__/settings.cpython-33.pyc | |
-# new file: superlists/__pycache__/urls.cpython-33.pyc | |
-# new file: superlists/__pycache__/wsgi.cpython-33.pyc | |
-# new file: superlists/settings.py | |
-# new file: superlists/urls.py | |
-# new file: superlists/wsgi.py | |
-# | |
+On branch master | |
+ | |
+Initial commit | |
+ | |
+Changes to be committed: | |
+ (use "git rm --cached <file>..." to unstage) | |
+ | |
+ new file: .gitignore | |
+ new file: functional_tests.py | |
+ new file: manage.py | |
+ new file: superlists/__init__.py | |
+ new file: superlists/__pycache__/__init__.cpython-34.pyc | |
+ new file: superlists/__pycache__/settings.cpython-34.pyc | |
+ new file: superlists/__pycache__/urls.cpython-34.pyc | |
+ new file: superlists/__pycache__/wsgi.cpython-34.pyc | |
+ new file: superlists/settings.py | |
+ new file: superlists/urls.py | |
+ new file: superlists/wsgi.py | |
---- | |
@@ -295,10 +294,10 @@ commit those. Let's remove them from git and add them to | |
[subs="specialcharacters,macros"] | |
---- | |
$ pass:quotes[*git rm -r --cached superlists/__pycache__*] | |
-rm 'superlists/__pycache__/__init__.cpython-33.pyc' | |
-rm 'superlists/__pycache__/settings.cpython-33.pyc' | |
-rm 'superlists/__pycache__/urls.cpython-33.pyc' | |
-rm 'superlists/__pycache__/wsgi.cpython-33.pyc' | |
+rm 'superlists/__pycache__/__init__.cpython-34.pyc' | |
+rm 'superlists/__pycache__/settings.cpython-34.pyc' | |
+rm 'superlists/__pycache__/urls.cpython-34.pyc' | |
+rm 'superlists/__pycache__/wsgi.cpython-34.pyc' | |
$ pass:quotes[@echo "__pycache__" >> .gitignore@] | |
$ pass:quotes[@echo "*.pyc" >> .gitignore@] | |
---- | |
@@ -311,21 +310,20 @@ though, I leave you to discover the secrets of git aliases on your own!) | |
[subs="specialcharacters,quotes"] | |
---- | |
$ *git status* | |
-# On branch master | |
-# | |
-# Initial commit | |
-# | |
-# Changes to be committed: | |
-# (use "git rm --cached <file>..." to unstage) | |
-# | |
-# new file: .gitignore | |
-# new file: functional_tests.py | |
-# new file: manage.py | |
-# new file: superlists/__init__.py | |
-# new file: superlists/settings.py | |
-# new file: superlists/urls.py | |
-# new file: superlists/wsgi.py | |
-# | |
+On branch master | |
+ | |
+Initial commit | |
+ | |
+Changes to be committed: | |
+ (use "git rm --cached <file>..." to unstage) | |
+ | |
+ new file: .gitignore | |
+ new file: functional_tests.py | |
+ new file: manage.py | |
+ new file: superlists/__init__.py | |
+ new file: superlists/settings.py | |
+ new file: superlists/urls.py | |
+ new file: superlists/wsgi.py | |
---- | |
Looking good, we're ready to do our first commit! | |
diff --git a/chapter_03.asciidoc b/chapter_03.asciidoc | |
index bffae6c..9d43371 100644 | |
--- a/chapter_03.asciidoc | |
+++ b/chapter_03.asciidoc | |
@@ -268,10 +268,10 @@ What's going on here? | |
So, what do you think will happen when we run the tests? | |
-[subs="specialcharacters,quotes"] | |
+[subs="specialcharacters,macros"] | |
---- | |
-$ *python3 manage.py test* | |
-ImportError: cannot import name home_page | |
+$ pass:quotes[*python3 manage.py test*] | |
+ImportError: cannot import name 'home_page' | |
---- | |
It's a very predictable and uninteresting error: we tried to import something | |
@@ -327,14 +327,14 @@ Traceback (most recent call last): | |
File "/workspace/superlists/lists/tests.py", line 8, in | |
test_root_url_resolves_to_home_page_view | |
found = resolve('/') | |
- File "/usr/local/lib/python3.3/dist-packages/django/core/urlresolvers.py", | |
+ File "/usr/local/lib/python3.4/dist-packages/django/core/urlresolvers.py", | |
line 462, in resolve | |
return get_resolver(urlconf).resolve(path) | |
- File "/usr/local/lib/python3.3/dist-packages/django/core/urlresolvers.py", | |
+ File "/usr/local/lib/python3.4/dist-packages/django/core/urlresolvers.py", | |
line 334, in resolve | |
raise Resolver404({'tried': tried, 'path': new_path}) | |
-django.core.urlresolvers.Resolver404: {'path': '', 'tried': [[<RegexURLResolver | |
-<RegexURLPattern list> (admin:admin) ^admin/>]]} | |
+django.core.urlresolvers.Resolver404: {'tried': [[<RegexURLResolver | |
+<RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''} | |
--------------------------------------------------------------------- | |
Ran 1 test in 0.002s | |
@@ -359,14 +359,14 @@ Traceback (most recent call last): | |
File "/workspace/superlists/lists/tests.py", line 8, in | |
test_root_url_resolves_to_home_page_view | |
found = resolve('/')<3> | |
- File "/usr/local/lib/python3.3/dist-packages/django/core/urlresolvers.py", | |
+ File "/usr/local/lib/python3.4/dist-packages/django/core/urlresolvers.py", | |
line 462, in resolve | |
return get_resolver(urlconf).resolve(path) | |
- File "/usr/local/lib/python3.3/dist-packages/django/core/urlresolvers.py", | |
+ File "/usr/local/lib/python3.4/dist-packages/django/core/urlresolvers.py", | |
line 334, in resolve | |
raise Resolver404({'tried': tried, 'path': new_path}) | |
-django.core.urlresolvers.Resolver404: {'path': '', 'tried': [[<RegexURLResolver<1> | |
-<RegexURLPattern list> (admin:admin) ^admin/>]]}<1> | |
+django.core.urlresolvers.Resolver404: {'tried': [[<RegexURLResolver | |
+<RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''} | |
--------------------------------------------------------------------- | |
[...] | |
---- | |
diff --git a/chapter_05.asciidoc b/chapter_05.asciidoc | |
index 0c0ad72..771d634 100644 | |
--- a/chapter_05.asciidoc | |
+++ b/chapter_05.asciidoc | |
@@ -677,8 +677,9 @@ We'll come back to this and talk about unit tests and integrated tests in | |
Let's try running the unit test. Here comes another unit test/code cycle: | |
+[subs="specialcharacters,macros"] | |
---- | |
-ImportError: cannot import name Item | |
+ImportError: cannot import name 'Item' | |
---- | |
Very well, let's give it something to import from 'lists/models.py'. We're | |
@@ -724,7 +725,7 @@ And the next thing that happens is a database error: | |
---- | |
first_item.save() | |
- File "/usr/local/lib/python3.3/dist-packages/django/db/models/base.py", line | |
+ File "/usr/local/lib/python3.4/dist-packages/django/db/models/base.py", line | |
603, in save | |
[...] | |
return Database.Cursor.execute(self, query, params) | |
@@ -1305,7 +1306,7 @@ it to creating a real database, we use another Django Swiss army knife | |
---- | |
$ pass:quotes[*python3 manage.py migrate*] | |
Operations to perform: | |
- Synchronize unmigrated apps: admin, contenttypes, auth, sessions | |
+ Synchronize unmigrated apps: contenttypes, sessions, admin, auth | |
Apply all migrations: lists | |
Synchronizing apps without migrations: | |
Creating tables... | |
diff --git a/chapter_06.asciidoc b/chapter_06.asciidoc | |
index ade6e13..1b0b0fb 100644 | |
--- a/chapter_06.asciidoc | |
+++ b/chapter_06.asciidoc | |
@@ -1288,8 +1288,9 @@ each minimal code change should be on your own: | |
Your first error should be: | |
+[subs="specialcharacters,macros"] | |
---- | |
-ImportError: cannot import name List | |
+ImportError: cannot import name 'List' | |
---- | |
Fix that, then you should see: | |
@@ -1423,13 +1424,13 @@ Back in our tests, now what happens? | |
$ pass:quotes[*python3 manage.py test lists*] | |
[...] | |
ERROR: test_displays_all_items (lists.tests.ListViewTest) | |
-django.db.utils.IntegrityError: lists_item.list_id may not be NULL | |
+sqlite3.IntegrityError: NOT NULL constraint failed: lists_item.list_id | |
[...] | |
ERROR: test_redirects_after_POST (lists.tests.NewListTest) | |
-django.db.utils.IntegrityError: lists_item.list_id may not be NULL | |
+sqlite3.IntegrityError: NOT NULL constraint failed: lists_item.list_id | |
[...] | |
ERROR: test_saving_a_POST_request (lists.tests.NewListTest) | |
-django.db.utils.IntegrityError: lists_item.list_id may not be NULL | |
+sqlite3.IntegrityError: NOT NULL constraint failed: lists_item.list_id | |
Ran 7 tests in 0.021s | |
diff --git a/chapter_07.asciidoc b/chapter_07.asciidoc | |
index 6b4ad78..107418c 100644 | |
--- a/chapter_07.asciidoc | |
+++ b/chapter_07.asciidoc | |
@@ -596,12 +596,10 @@ Ran 2 tests in 9.764s | |
NOTE: At this point, Windows users may see some (harmless, but distracting) | |
error messages that say `socket.error: [WinError 10054] An existing | |
- connection was forcibly closed by the remote host`. It's safe to ignore | |
- these. I've reported it as a | |
- https://code.djangoproject.com/ticket/22252[bug on the Django tracker]. | |
- Incidentally, I did my very best to make it a "good" bug report, including | |
- a minimal repro, which you should always include if you can when reporting | |
- bugs to open source projects. | |
+ connection was forcibly closed by the remote host`. Add a | |
+ `self.browser.refresh()` just before the `self.browser.quit()` in | |
+ `tearDown` to get rid of them. The issue is being tracked in this | |
+ https://code.djangoproject.com/ticket/21227[bug on the Django tracker]. | |
Hooray! | |
diff --git a/chapter_11.asciidoc b/chapter_11.asciidoc | |
index d32b0be..e7a6110 100644 | |
--- a/chapter_11.asciidoc | |
+++ b/chapter_11.asciidoc | |
@@ -465,8 +465,9 @@ Now the old test is out of date: | |
---- | |
self.assertEqual(response.content.decode(), expected_html) | |
-AssertionError: '<!DOCTYPE html>\n<html lang="en">\n\n<head>\n <title>To-Do | |
-[...] | |
+AssertionError: '<!DO[596 chars] <input class="form-control input-lg" | |
+id="[342 chars]l>\n' != '<!DO[596 chars] \n \n | |
+[233 chars]l>\n' | |
---- | |
That error message is impossible to read though. Let's clarify it's message a | |
@@ -1076,7 +1077,7 @@ let's see what happens if we just try to call `form.save()`: | |
Django isn't happy, because an item needs to belong to a list: | |
---- | |
-django.db.utils.IntegrityError: lists_item.list_id may not be NULL | |
+django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id | |
---- | |
Our solution is to tell the form's save method what list it should save to: | |
diff --git a/chapter_12.asciidoc b/chapter_12.asciidoc | |
index e508c56..d84433d 100644 | |
--- a/chapter_12.asciidoc | |
+++ b/chapter_12.asciidoc | |
@@ -479,9 +479,11 @@ It gives: | |
ERROR: test_duplicate_items_are_invalid (lists.tests.test_models.ItemModelTest) | |
[...] | |
return Database.Cursor.execute(self, query, params) | |
-sqlite3.IntegrityError: columns list_id, text are not unique | |
+sqlite3.IntegrityError: UNIQUE constraint failed: lists_item.list_id, | |
+lists_item.text | |
[...] | |
-django.db.utils.IntegrityError: columns list_id, text are not unique | |
+django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id, | |
+lists_item.text | |
---- | |
Note that it's a different error to the one we want, an `IntegrityError` | |
@@ -556,7 +558,8 @@ class ListViewTest(TestCase): | |
Gives: | |
---- | |
-django.db.utils.IntegrityError: columns list_id, text are not unique | |
+django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id, | |
+lists_item.text | |
---- | |
We want to avoid integrity errors! Ideally, we want the call to `is_valid` to | |
@@ -730,7 +733,8 @@ from lists.forms import ( | |
That brings back out integrity error: | |
---- | |
-django.db.utils.IntegrityError: columns list_id, text are not unique | |
+django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id, | |
+lists_item.text | |
---- | |
Our fix for this to switch to using the new form class. Before we implement | |
diff --git a/chapter_15.asciidoc b/chapter_15.asciidoc | |
index f13ae0e..b63fda7 100644 | |
--- a/chapter_15.asciidoc | |
+++ b/chapter_15.asciidoc | |
@@ -705,7 +705,7 @@ $ pass:quotes[*python3 manage.py test functional_tests.test_login*] | |
Creating test database for alias 'default'... | |
Not Found: /favicon.ico | |
login view | |
-sending to mozilla {'audience': [...] | |
+sending to mozilla {'assertion': [...] | |
[...] | |
got b'{"audience":"localhost","expires":[...] | |
diff --git a/chapter_16.asciidoc b/chapter_16.asciidoc | |
index 9b83fc8..7303cff 100644 | |
--- a/chapter_16.asciidoc | |
+++ b/chapter_16.asciidoc | |
@@ -1174,9 +1174,9 @@ in test_returns_none_if_user_does_not_exist | |
self.assertIsNone(backend.get_user('[email protected]'))<1> | |
File "/workspace/superlists/accounts/authentication.py", line 25, in get_user | |
return User.objects.get(email=email)<2> | |
- File "/usr/lib/python3.3/unittest/mock.py", line 846, in __call__ | |
+ File "/usr/lib/python3.4/unittest/mock.py", line 885, in __call__ | |
return _mock_self._mock_call(*args, **kwargs)<3> | |
- File "/usr/lib/python3.3/unittest/mock.py", line 911, in _mock_call | |
+ File "/usr/lib/python3.4/unittest/mock.py", line 951, in _mock_call | |
ret_val = effect(*args, **kwargs) | |
File "/workspace/superlists/accounts/tests/test_authentication.py", line 69, | |
in raise_no_user_error<4> | |
diff --git a/chapter_17.asciidoc b/chapter_17.asciidoc | |
index aaae7d7..9a7da41 100644 | |
--- a/chapter_17.asciidoc | |
+++ b/chapter_17.asciidoc | |
@@ -212,7 +212,7 @@ the staging server? Let's try and deploy our site. Along the way we'll catch | |
an unexpected bug, and then we'll have to figure out a way of managing the | |
database on the test server. | |
-//IDEA unskip all these | |
+//IDEA: unskip all these | |
[role="skipme"] | |
[subs="specialcharacters,quotes"] | |
---- | |
diff --git a/chapter_19.asciidoc b/chapter_19.asciidoc | |
index 30d49b7..288c211 100644 | |
--- a/chapter_19.asciidoc | |
+++ b/chapter_19.asciidoc | |
@@ -2,8 +2,6 @@ | |
Test Isolation, and "listening to your tests" | |
--------------------------------------------- | |
- | |
- | |
In the last chapter, we made the decision to leave a unit test failing in | |
the views layer while we proceeded to write more tests and more code at | |
the models layer to get it to pass. | |
@@ -693,43 +691,48 @@ Moving down to the forms layer | |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
So we've built up our view function based on a "wishful thinking" version | |
-of a form called `NewItemForm`, which doesn't even exist yet. Let's write | |
-some unit tests for it now. | |
+of a form called `NewItemForm`, which doesn't even exist yet. | |
-Once again, they'll be fully isolated from the layers below, which in this | |
-case is the models layer. So, we mock out the `Item` and `List` classes | |
-of the ORM: | |
+We'll need the form's save method to create a new list, and a new item based on | |
+the text from the form's validated POST data. If we were to just dive in and | |
+use the ORM, the code might look something a bit like this: | |
-[role="sourcecode"] | |
-.lists/tests/test_forms.py (ch19l021) | |
+[role="skipme"] | |
[source,python] | |
---- | |
-import unittest | |
-from unittest.mock import patch, Mock | |
-from django.test import TestCase | |
+class NewListForm(models.Form): | |
-from lists.forms import ( | |
- DUPLICATE_ITEM_ERROR, EMPTY_LIST_ERROR, | |
- ExistingListItemForm, ItemForm, NewListForm | |
-) | |
+ def save(self, owner): | |
+ list_ = List() | |
+ if owner: | |
+ list_.owner owner | |
+ list_.save() | |
+ item = Item() | |
+ item.list = list_ | |
+ item.text = self.cleaned_data['text'] | |
+ item.save() | |
+---- | |
+This implementation depends on two classes from the model layer, `Item` and | |
+`List`. So, what would a well isolated test look like? | |
-class ItemFormTest(TestCase): | |
- [...] | |
-@patch('lists.forms.List') #<1> | |
-@patch('lists.forms.Item') #<1> | |
+[role="skipme"] | |
+[source,python] | |
+---- | |
class NewListFormTest(unittest.TestCase): | |
- def test_save_creates_new_list_and_item_from_cleaned_data( | |
+ @patch('lists.forms.List') #<1> | |
+ @patch('lists.forms.Item') #<1> | |
+ def test_save_creates_new_list_and_item_from_post_data( | |
self, mockItem, mockList #<1> | |
): | |
mock_item = mockItem.return_value | |
mock_list = mockList.return_value | |
user = Mock() | |
- form = NewListForm() | |
- form.cleaned_data = {'text': 'new item text'} #<2> | |
+ form = NewListForm(data={'text': 'new item text'}) | |
+ form.is_valid() #<2> | |
def check_item_text_and_list(): | |
self.assertEqual(mock_item.text, 'new item text') | |
@@ -744,10 +747,8 @@ class NewListFormTest(unittest.TestCase): | |
<1> We mock out the two collaborators for our form from the models layer below | |
-<2> Django forms store validated data in a dictionary called `.cleaned_data`. | |
- Normally, it's populated when you call `form.is_valid()`, but we'll assign | |
- it directly. Writing more isolated test does require you to know more about | |
- the internals of the tools you use. | |
+<2> We need to call `is_valid()` so that the form populates the `.cleaned_data` | |
+ dictionary where it stores validated data. | |
<3> We use the `side_effect` method to make sure that, when we save the new | |
item object, we're doing so with a saved List and with the correct item | |
@@ -755,166 +756,166 @@ class NewListFormTest(unittest.TestCase): | |
<4> As always, we double-check that our side-effect function was actually called. | |
+Yuck! What an ugly test! | |
-Comparing isolated and integrated versions of the same test | |
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | |
-Compare this test to the old form save test in `ItemFormTest`: | |
+Keep listening to your tests: removing ORM code from our application | |
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | |
-[role="sourcecode currentcontents"] | |
-.lists/tests/test_forms.py | |
+Again, these tests are trying to tell us something: the Django ORM | |
+is hard to mock out, and our form class needs to know too much about | |
+how it works. Programming by wishful thinking again, what would | |
+be a simpler API that our form could use? How about something like | |
+this: | |
+ | |
+ | |
+[role="skipme"] | |
[source,python] | |
---- | |
-class ItemFormTest(TestCase): | |
- [...] | |
- | |
- def test_form_save_handles_saving_to_a_list(self): | |
- list_ = List.objects.create() | |
- form = ItemForm(data={'text': 'do me'}) | |
- new_item = form.save(for_list=list_) | |
- self.assertEqual(new_item, Item.objects.first()) | |
- self.assertEqual(new_item.text, 'do me') | |
- self.assertEqual(new_item.list, list_) | |
+ def save(self): | |
+ List.create_new(first_item_text=self.cleaned_data['text']) | |
---- | |
-It seems much more straightforward! | |
- | |
-On the one hand, the isolated test is harder to read. It takes a good deal | |
-of wrapping your head around, and it's very sensitive to slight changes in the | |
-way we use the model API (it will break if we use `Item.objects.create` instead | |
-of `Item()`, for example). | |
+Our wishful thinking says: how about we had a helper method that | |
+would live on the List class | |
+footnote:[it could easily just be a standalone function, but hanging it on the | |
+model class is a nice way to keep track of where it lives, and gives a bit | |
+more of a hint as to what it will do] | |
+and it will encapsulate all the logic of saving a new list object and | |
+its associated first item. | |
-On the other hand, the mocky test is a lot more clear about what the | |
-dependencies of our code are. It's harder work, but it encourages us | |
-to keep clean separations between the layers of our code. | |
+So let's write a test for that instead: | |
-.Don't be put off by my bad example! | |
-******************************************************************************* | |
-I'm worried you'll come out of this chapter with the impression that isolated | |
-tests always look horrible and involve hundreds of unreadable mocks. They | |
-really don't! Particularly when you're working with code that's totally | |
-under your own control, well isolated tests can be just as easy to read | |
-and write as integrated tests. | |
- | |
-The trouble is that we're working on a very simple Django app, where almost | |
-every single code involves some external API that we don't control, like the | |
-Django ORM. I wish I was able to demo some code that was totally independent | |
-of Django, but it was hard to fit in with our example project. | |
- | |
-So hold on to the benefits: when dealing with more complex codebases, writing | |
-isolated tests will help you to test-drive better designed code, by identifying | |
-your dependencies clearly. It will lead you to write code that's more modular, | |
-and less tightly coupled, and everything will be wonderful. | |
- | |
-But for smaller apps, they may not be worth the effort. We'll come back to | |
-this in <<hot-lava-chapter>>>>. | |
-******************************************************************************* | |
- | |
- | |
-If we step through the requirements of this tests, we should eventually get to | |
-code like this: | |
- | |
[role="sourcecode"] | |
-.lists/forms.py (ch19l022) | |
+.lists/tests/test_forms.py (ch19l021) | |
[source,python] | |
---- | |
+import unittest | |
+from unittest.mock import patch, Mock | |
+from django.test import TestCase | |
+ | |
+from lists.forms import ( | |
+ DUPLICATE_ITEM_ERROR, EMPTY_LIST_ERROR, | |
+ ExistingListItemForm, ItemForm, NewListForm | |
+) | |
from lists.models import Item, List | |
[...] | |
-class NewListForm(object): | |
- def save(self, owner): | |
- list_ = List() | |
- list_.save() | |
- item = Item() | |
- item.list = list_ | |
- item.text = self.cleaned_data['text'] | |
- item.save() | |
----- | |
+class NewListFormTest(unittest.TestCase): | |
-But our form isn't a form yet! Let's make it inherit from `ItemForm`, | |
-which will get us the css classes and validation for free: | |
+ @patch('lists.forms.List.create_new') | |
+ def test_save_creates_new_list_from_post_data_if_user_not_authenticated( | |
+ self, mock_List_create_new | |
+ ): | |
+ user = Mock(is_authenticated=lambda: False) | |
+ form = NewListForm(data={'text': 'new item text'}) | |
+ form.is_valid() | |
+ form.save(owner=user) | |
+ mock_List_create_new.assert_called_once_with( | |
+ first_item_text='new item text' | |
+ ) | |
+---- | |
+And while we're at it we can test the case where the user is an authenticated | |
+user too: | |
[role="sourcecode"] | |
-.lists/tests/test_forms.py (ch19l023) | |
+.lists/tests/test_forms.py (ch19l022) | |
[source,python] | |
---- | |
- def test_is_an_ItemForm(self, mockItem, mockList): | |
- self.assertIsInstance(NewListForm(), ItemForm) | |
+ @patch('lists.forms.List.create_new') | |
+ def test_save_creates_new_list_with_owner_if_user_authenticatd( | |
+ self, mock_List_create_new | |
+ ): | |
+ user = Mock(is_authenticated=lambda: True) | |
+ form = NewListForm(data={'text': 'new item text'}) | |
+ form.is_valid() | |
+ form.save(owner=user) | |
+ mock_List_create_new.assert_called_once_with( | |
+ first_item_text='new item text', owner=user | |
+ ) | |
---- | |
-So: | |
+You can see this is a much more readable test. Let's start implementing | |
+our new form. We start with the import: | |
[role="sourcecode"] | |
-.lists/forms.py (ch19l024) | |
+.lists/forms.py (ch19l023) | |
[source,python] | |
---- | |
- class NewListForm(ItemForm): | |
- [...] | |
+from lists.models import Item, List | |
---- | |
+Now mock tells us to create a placeholder for our `create_new` method: | |
-Conditionally saving owners | |
-^^^^^^^^^^^^^^^^^^^^^^^^^^^ | |
- | |
-Here's where we implement our new functionality: the form should conditionally | |
-save the owner: | |
+[subs="specialcharacters,macros"] | |
+---- | |
+AttributeError: <class 'lists.models.List'> does not have the attribute | |
+'create_new' | |
+---- | |
[role="sourcecode"] | |
-.lists/tests/test_forms.py (ch19l025) | |
+.lists/models.py | |
[source,python] | |
---- | |
- def test_save_saves_owner_if_authenticated(self, mockItem, mockList): | |
- mock_list = mockList.return_value | |
- mock_list.owner = None | |
- user = Mock(is_authenticated=lambda: True) | |
- form = NewListForm() | |
- form.cleaned_data = {'text': 'new item text'} | |
+class List(models.Model): | |
- form.save(owner=user) | |
+ def get_absolute_url(self): | |
+ return reverse('view_list', args=[self.id]) | |
- self.assertEqual(mock_list.owner, user) | |
+ def create_new(): | |
+ pass | |
+---- | |
+//24 | |
- def test_does_not_save_owner_if_not_authenticated(self, mockItem, mockList): | |
- mock_list = mockList.return_value | |
- mock_list.owner = None | |
- user = Mock(is_authenticated=lambda: False) | |
- form = NewListForm() | |
- form.cleaned_data = {'text': 'new item text'} | |
+And after a few steps, we should end up with a form save method like this: | |
- form.save(owner=user) | |
- | |
- self.assertEqual(mock_list.owner, None) | |
+[role="sourcecode"] | |
+.lists/forms.py (ch19l025) | |
+[source,python] | |
---- | |
+class NewListForm(ItemForm): | |
+ def save(self, owner): | |
+ if owner.is_authenticated(): | |
+ List.create_new(first_item_text=self.cleaned_data['text'], owner=owner) | |
+ else: | |
+ List.create_new(first_item_text=self.cleaned_data['text']) | |
+---- | |
-One of those two will fail: | |
+And passing tests. | |
[subs="specialcharacters,macros"] | |
---- | |
$ pass:quotes[*python3 manage.py test lists*] | |
-[...] | |
-AssertionError: None != <Mock id='140348870426896'> | |
-[...] | |
-Ran 46 tests in 0.172s | |
-FAILED (failures=1) | |
+Ran 44 tests in 0.192s | |
+ | |
+OK | |
---- | |
+.Pattern: Hiding ORM code behind helper methods | |
+******************************************************************************* | |
+One of the patterns that emerged from our use of isolated tests was the | |
+"ORM helper method" pattern. | |
-And that should take us to this: | |
+Django's ORM lets you get things done quickly with a reasonable readable | |
+syntax (it's certainly much nicer than raw SQL!). But some people like to | |
+try and minimise the amount of ORM code in the application -- particularly | |
+removing it from the views and forms layers. | |
-[role="sourcecode"] | |
-.lists/forms.py | |
+One reason is that it makes it much easier to test those layers. But another | |
+is that it forces us to build helper functions that express our domain | |
+logic more clearly: | |
+ | |
+ | |
+[role="skipme"] | |
[source,python] | |
---- | |
- def save(self, owner): | |
list_ = List() | |
- if owner.is_authenticated(): | |
- list_.owner = owner | |
list_.save() | |
item = Item() | |
item.list = list_ | |
@@ -922,31 +923,119 @@ And that should take us to this: | |
item.save() | |
---- | |
+With: | |
-And passing tests. | |
+[role="skipme"] | |
+[source,python] | |
+---- | |
+ List.create_new(first_item_text=self.cleaned_data['text']) | |
+---- | |
+ | |
+This also applies to read queries as well as write. Imagine something like | |
+this: | |
+[role="skipme"] | |
+[source,python] | |
---- | |
-OK | |
+ Book.objects.filter(in_print=True, pub_date__lte=datetime.today()) | |
---- | |
+Versus a helper method, like: | |
+ | |
+[role="skipme"] | |
+[source,python] | |
+---- | |
+ Book.all_available_books() | |
+---- | |
+ | |
+When we build helper functions, we can give them names that express what we | |
+are doing in terms of the business domain, which can actually make our code | |
+more legible, as well as giving us the benefit of keeping all ORM calls at | |
+the model layer. | |
+ | |
+******************************************************************************* | |
+ | |
+ | |
Finally, moving down to the models layer | |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
-Notice that we've been able to drive all this design, and the models layer | |
-still doesn't actually implement lists having owners! We can finally put that | |
-together now, using the same tests we wrote in the last chapter: | |
+At the models layer, we no longer need to write isolated tests - the whole | |
+point of the models layer is to integrate with the database, so it's appropriate | |
+to write integrated tests: | |
+ | |
+[role="sourcecode"] | |
+.lists/tests/test_models.py (ch19l026) | |
+[source,python] | |
+---- | |
+class ListModelTest(TestCase): | |
+ | |
+ def test_get_absolute_url(self): | |
+ list_ = List.objects.create() | |
+ self.assertEqual(list_.get_absolute_url(), '/lists/%d/' % (list_.id,)) | |
+ | |
+ | |
+ def test_create_new_creates_list_and_first_item(self): | |
+ List.create_new(first_item_text='new item text') | |
+ new_item = Item.objects.first() | |
+ self.assertEqual(new_item.text, 'new item text') | |
+ new_list = List.objects.first() | |
+ self.assertEqual(new_item.list, new_list) | |
+---- | |
+ | |
+Which gives | |
+ | |
+[subs="specialcharacters,macros"] | |
+---- | |
+TypeError: create_new() got an unexpected keyword argument 'first_item_text' | |
+---- | |
+ | |
+And that will take us to a first cut implementation that looks like this: | |
+ | |
+[role="sourcecode"] | |
+.lists/models.py (ch19l027) | |
+[source,python] | |
+---- | |
+class List(models.Model): | |
+ | |
+ def get_absolute_url(self): | |
+ return reverse('view_list', args=[self.id]) | |
+ | |
+ @staticmethod | |
+ def create_new(first_item_text): | |
+ list_ = List.objects.create() | |
+ Item.objects.create(text=first_item_text, list=list_) | |
+---- | |
+ | |
+Notice we've been able to get all the way down to the models layer, | |
+driving a nice design for the views and forms layers, and the List | |
+model still doesn't support having an owner! | |
+ | |
+Now let's test the case where the list should have an owner, and | |
+add | |
[role="sourcecode"] | |
-.lists/tests/test_models.py (ch19l027) | |
+.lists/tests/test_models.py (ch19l028) | |
[source,python] | |
---- | |
from django.contrib.auth import get_user_model | |
User = get_user_model() | |
[...] | |
+ def test_create_new_optionally_saves_owner(self): | |
+ user = User.objects.create() | |
+ List.create_new(first_item_text='new item text', owner=user) | |
+ new_list = List.objects.first() | |
+ self.assertEqual(new_list.owner, user) | |
+---- | |
+ | |
+And while we're at it, we can write the tests for the new owner attribute: | |
+ | |
+[role="sourcecode"] | |
+.lists/tests/test_models.py (ch19l029) | |
+[source,python] | |
+---- | |
class ListModelTest(TestCase): | |
- [...] | |
def test_lists_can_have_owners(self): | |
List(owner=User()) # should not raise | |
@@ -956,22 +1045,34 @@ class ListModelTest(TestCase): | |
List().full_clean() # should not raise | |
---- | |
-Now that we're at the models layer, we're at a boundary of the system, so | |
-it's OK not to use mocks any more -- we can use the real ORM, and even | |
-use the database if we need to. | |
+These two are almost exactly the same tests we used in the last chapter, | |
+but I've re-written them slightly so they don't actually save objects -- just | |
+having them as in-memory objects is enough to for this test. | |
-Although in this case, I have managed to re-write the tests so that they don't | |
-actually save objects -- just having them as in-memory objects is enough to for | |
-this test. | |
+TIP: Use in-memory (unsaved) model objects in your tests whenever you can, it | |
+ makes your tests faster. | |
-TIP: Don't save models objects to the database if you don't need to. It will | |
- make your tests faster. | |
+That gives: | |
+ | |
+[subs="specialcharacters,macros"] | |
+---- | |
+$ pass:quotes[*python3 manage.py test lists*] | |
+[...] | |
+ERROR: test_create_new_optionally_saves_owner | |
+TypeError: create_new() got an unexpected keyword argument 'owner' | |
+[...] | |
+ERROR: test_lists_can_have_owners (lists.tests.test_models.ListModelTest) | |
+TypeError: 'owner' is an invalid keyword argument for this function | |
+[...] | |
+Ran 48 tests in 0.204s | |
+FAILED (errors=2) | |
+---- | |
We implement, just like we did in the last chapter: | |
[role="sourcecode"] | |
-.lists/models.py (ch19l028) | |
+.lists/models.py (ch19l030-1) | |
[source,python] | |
---- | |
from django.conf import settings | |
@@ -983,10 +1084,48 @@ class List(models.Model): | |
[...] | |
---- | |
-And a migration, as usual... takes us to failing integrated tests! What's | |
-happening? | |
+That will give us all sorts of integrity failures, until we do a migration | |
+ | |
+---- | |
+django.db.utils.OperationalError: no such column: lists_list.owner_id | |
+ | |
+FAILED (errors=28) | |
+---- | |
+ | |
+Building the migration will get us down to 3 failures | |
+ | |
+[role="dofirst-ch19l030-2"] | |
+[subs="specialcharacters,macros"] | |
+---- | |
+ERROR: test_create_new_optionally_saves_owner | |
+TypeError: create_new() got an unexpected keyword argument 'owner' | |
+[...] | |
+ValueError: Cannot assign "<SimpleLazyObject: | |
+<django.contrib.auth.models.AnonymousUser object at 0x7f5b2380b4e0>>": | |
+"List.owner" must be a "User" instance. | |
+ValueError: Cannot assign "<SimpleLazyObject: | |
+<django.contrib.auth.models.AnonymousUser object at 0x7f5b237a12e8>>": | |
+"List.owner" must be a "User" instance. | |
+---- | |
+ | |
+Let's deal with the first one, which is for our `create_new` method: | |
+ | |
+[role="sourcecode"] | |
+.lists/models.py (ch19l030-3) | |
+[source,python] | |
+---- | |
+ @staticmethod | |
+ def create_new(first_item_text, owner=None): | |
+ list_ = List.objects.create(owner=owner) | |
+ Item.objects.create(text=first_item_text, list=list_) | |
+---- | |
+ | |
+ | |
+Back to views | |
+^^^^^^^^^^^^^ | |
+ | |
+Two of our old integrated tests for the views layer are failing. What's happening? | |
-[role="dofirst-ch19l029"] | |
---- | |
ValueError: Cannot assign "<SimpleLazyObject: | |
<django.contrib.auth.models.AnonymousUser object at 0x7fbad1cb6c10>>": | |
@@ -1032,7 +1171,7 @@ def new_list2(request): | |
---- | |
NOTE: One of the benefits of integrated tests is that they help you to catch | |
- unpredictable interactions like this. We'd forgotten about to write a test | |
+ less predictable interactions like this. We'd forgotten about to write a test | |
for the case where the user is not authenticated, but because the integrated | |
tests use the stack all the way down, errors from the model layer came up | |
to let us know we'd forgotten something, | |
@@ -1125,8 +1264,10 @@ But we forgot to make it return anything: | |
[source,python] | |
---- | |
def save(self, owner): | |
- [...] | |
- item.save() | |
+ if owner.is_authenticated(): | |
+ List.create_new(first_item_text=self.cleaned_data['text'], owner=owner) | |
+ else: | |
+ List.create_new(first_item_text=self.cleaned_data['text']) | |
---- | |
@@ -1209,13 +1350,13 @@ what each mock is saying about the implicit contract: | |
---- | |
<1> We need to be able to initialise our form by passing it a POST request | |
- as data | |
+ as data. | |
<2> It should have an `is_valid()` function which returns True or False | |
appropriately, based on the input data. | |
<3> The form should have a `.save` method which will accept a `request.user`, | |
- which may or may not be a logged-in user, and deal with it appropriately | |
+ which may or may not be a logged-in user, and deal with it appropriately. | |
<4> The form's `.save` method should return a new list object, for our view | |
to redirect the user to. | |
@@ -1229,63 +1370,108 @@ But contract clause number %4% managed to slip through the net. | |
NOTE: When doing outside-in TDD with isolated tests, you need to keep track of | |
each test's implicit assumptions about the contract which the next layer | |
- should implement, and remember to test each of those in turn later. This | |
- is another good place to use our scratchpad. | |
- | |
+ should implement, and remember to test each of those in turn later. You | |
+ could use our scratchpad for this, or create a placeholder test with | |
+ a `self.fail`. | |
Fixing the oversight | |
^^^^^^^^^^^^^^^^^^^^ | |
-Let's fix that now: | |
- | |
+Let's add a new test that our form should return the new saved list: | |
[role="sourcecode"] | |
-.lists/tests/test_forms.py (ch19l038) | |
+.lists/tests/test_forms.py (ch19l038-1) | |
[source,python] | |
---- | |
- def test_save_returns_new_list_object(self, mockItem, mockList): | |
- mock_list = mockList.return_value | |
+ @patch('lists.forms.List.create_new') | |
+ def test_save_returns_new_list_object(self, mock_List_create_new): | |
user = Mock(is_authenticated=lambda: True) | |
- form = NewListForm() | |
- form.cleaned_data = {'text': 'new item text'} | |
- | |
+ form = NewListForm(data={'text': 'new item text'}) | |
+ form.is_valid() | |
response = form.save(owner=user) | |
- | |
- self.assertEqual(response, mock_list) | |
+ self.assertEqual(response, mock_List_create_new.return_value) | |
---- | |
+And, actually, this is a good example -- we have an implicit contract | |
+with the `List.create_new`, we want it to return the new list object. | |
+Let's add a placeholder test for that. | |
+[role="sourcecode"] | |
+.lists/tests/test_models.py (ch19l038-2) | |
+[source,python] | |
+---- | |
+class ListModelTest(TestCase): | |
+ [...] | |
-gives: | |
+ def test_create_returns_new_list_object(self): | |
+ self.fail() | |
+---- | |
+So, we have one test failures that's telling us to fix the form save: | |
---- | |
-AssertionError: None != <MagicMock name='List()' id='139707803945360'> | |
+AssertionError: None != <MagicMock name='create_new()' id='139802647565536'> | |
+FAILED (failures=2, errors=3) | |
---- | |
- | |
-And so: | |
+Like this: | |
[role="sourcecode"] | |
-.lists/forms.py | |
+.lists/forms.py (ch19l039-1) | |
[source,python] | |
---- | |
class NewListForm(ItemForm): | |
def save(self, owner): | |
- list_ = List() | |
if owner.is_authenticated(): | |
- list_.owner = owner | |
- list_.save() | |
- item = Item() | |
- item.list = list_ | |
- item.text = self.cleaned_data['text'] | |
- item.save() | |
- return list_ | |
+ return List.create_new(first_item_text=self.cleaned_data['text'], owner=owner) | |
+ else: | |
+ return List.create_new(first_item_text=self.cleaned_data['text']) | |
+---- | |
+ | |
+That's a start, now we should look at our placeholder test: | |
+ | |
+---- | |
+[...] | |
+FAIL: test_create_returns_new_list_object | |
+ self.fail() | |
+AssertionError: None | |
+ | |
+FAILED (failures=1, errors=3) | |
+---- | |
+ | |
+We flesh it out: | |
+ | |
+[role="sourcecode"] | |
+.lists/tests/test_models.py (ch19l039-2) | |
+[source,python] | |
+---- | |
+ def test_create_returns_new_list_object(self): | |
+ returned = List.create_new(first_item_text='new item text') | |
+ new_list = List.objects.first() | |
+ self.assertEqual(returned, new_list) | |
+---- | |
+ | |
+... | |
+ | |
+---- | |
+AssertionError: None != <List: List object> | |
---- | |
+And we add our return value: | |
+ | |
+[role="sourcecode"] | |
+.lists/models.py (ch19l039-3) | |
+[source,python] | |
+---- | |
+ @staticmethod | |
+ def create_new(first_item_text, owner=None): | |
+ list_ = List.objects.create(owner=owner) | |
+ Item.objects.create(text=first_item_text, list=list_) | |
+ return list_ | |
+---- | |
And that gets us to a fully passing test suite: | |
@@ -1293,7 +1479,7 @@ And that gets us to a fully passing test suite: | |
---- | |
$ pass:quotes[*python3 manage.py test lists*] | |
[...] | |
-Ran 49 tests in 0.169s | |
+Ran 50 tests in 0.169s | |
OK | |
---- | |
@@ -1302,7 +1488,18 @@ OK | |
One more test | |
~~~~~~~~~~~~~ | |
-We only have one more feature to implement, the `.name` attribute on list | |
+That's our code for saving list owners test-driven all the way down and | |
+working. But our functional test isn't passing quite yet: | |
+ | |
+[subs="specialcharacters,macros"] | |
+---- | |
+$ pass:quotes[*python3 manage.py test functional_tests.test_my_lists*] | |
+selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate | |
+element: {"method":"link text","selector":"Reticulate splines"}' ; Stacktrace: | |
+---- | |
+ | |
+ | |
+It's because we have one last feature to implement, the `.name` attribute on list | |
objects. Again, we can grab the test and code from the last chapter: | |
[role="sourcecode"] | |
@@ -1317,9 +1514,8 @@ objects. Again, we can grab the test and code from the last chapter: | |
---- | |
-(Since we're back at the boundary layer, it's OK to use the ORM again. You | |
-could conceivable write this test using mocks, but there wouldn't be much | |
-point). | |
+(Again, since this is a model-layer test, it's OK to use the ORM. You could | |
+conceivable write this test using mocks, but there wouldn't be much point). | |
[role="sourcecode"] | |
.lists/models.py (ch19l041) | |
@@ -1363,6 +1559,7 @@ We can get rid of the test for the old save method on the `ItemForm`: | |
--- a/lists/tests/test_forms.py | |
+++ b/lists/tests/test_forms.py | |
@@ -23,14 +23,6 @@ class ItemFormTest(TestCase): | |
+ | |
self.assertEqual(form.errors['text'], [EMPTY_LIST_ERROR]) | |
@@ -1377,39 +1574,6 @@ We can get rid of the test for the old save method on the `ItemForm`: | |
---- | |
-And if we add one new test for the `ExistingListItemForm` we can remove a | |
-little duplication from its tests: | |
- | |
- | |
-[role="sourcecode"] | |
-.lists/tests/test_forms.py | |
-[source,diff] | |
----- | |
---- a/lists/tests/test_forms.py | |
-+++ b/lists/tests/test_forms.py | |
-@@ -99,18 +90,8 @@ class NewListFormTest(unittest.TestCase): | |
- | |
- class ExistingListItemFormTest(TestCase): | |
- | |
-- def test_form_renders_item_text_input(self): | |
-- list_ = List.objects.create() | |
-- form = ExistingListItemForm(for_list=list_) | |
-- self.assertIn('placeholder="Enter a to-do item"', form.as_p()) | |
-- | |
-- | |
-- def test_form_validation_for_blank_items(self): | |
-- list_ = List.objects.create() | |
-- form = ExistingListItemForm(for_list=list_, data={'text': ''}) | |
-- self.assertFalse(form.is_valid()) | |
-- self.assertEqual(form.errors['text'], [EMPTY_LIST_ERROR]) | |
-- | |
-+ def test_is_an_ItemForm(self): | |
-+ self.assertIsInstance(ExistingListItemForm(for_list=List()), ItemForm) | |
- | |
- def test_form_validation_for_duplicate_items(self): | |
- | |
----- | |
- | |
And in our actual code, we can get rid of two redundant save methods in | |
'forms.py': | |
@@ -1419,7 +1583,8 @@ And in our actual code, we can get rid of two redundant save methods in | |
---- | |
--- a/lists/forms.py | |
+++ b/lists/forms.py | |
-@@ -24,11 +24,6 @@ class ItemForm(forms.models.ModelForm): | |
+@@ -22,11 +22,6 @@ class ItemForm(forms.models.ModelForm): | |
+ | |
self.fields['text'].error_messages['required'] = EMPTY_LIST_ERROR | |
@@ -1431,18 +1596,18 @@ And in our actual code, we can get rid of two redundant save methods in | |
class NewListForm(ItemForm): | |
-@@ -59,8 +54,3 @@ class ExistingListItemForm(ItemForm): | |
+@@ -52,8 +47,3 @@ class ExistingListItemForm(ItemForm): | |
+ | |
e.error_dict = {'text': [DUPLICATE_ITEM_ERROR]} | |
self._update_errors(e) | |
- | |
- | |
- | |
- def save(self): | |
- return forms.models.ModelForm.save(self) | |
- | |
- | |
---- | |
+ | |
Removing the old implementation of the view | |
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | |
@@ -1565,31 +1730,6 @@ test the full stack, from the request down to the actual database, and they | |
cover the three most important use cases of our view. | |
-.On the pros and cons of different types of test | |
-******************************************************************************* | |
- | |
-Functional tests:: | |
- * Provide the best guarantee that your application really works correctly, | |
- from the point of view of the user. | |
- * But: it's a slower feedback cycle, | |
- * And they don't necessarily help you write clean code. | |
- | |
-Integrated tests (reliant on, eg, the ORM or the Django Test Client):: | |
- * Are quick to write, | |
- * Easy to understand, | |
- * Will warn you of any integration issues, | |
- * But may not always drive good design (that's up to you!). | |
- * And are usually slower than isolated tests | |
- | |
-Isolated ("mocky") tests:: | |
- * These involve the most hard work. | |
- * They can be harder to read and understand, | |
- * But: these are the best ones for guiding you towards better design. | |
- * And they run the fastest. | |
- | |
-******************************************************************************* | |
- | |
- | |
Conclusions: When to write isolated vs integrated tests | |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
@@ -1639,11 +1779,11 @@ def new_list(request): | |
---- | |
-If we hadn't bothered to go down the isolation route, would we have bothered | |
-to refactor the view function? I know I didn't in the first draft of this | |
-book. I'd like to think I would have "in real life", but it's hard | |
-to be sure. But writing isolated tests does make you very aware of where the | |
-complexities in your code lie. | |
+If we hadn't bothered to go down the isolation route, would we have bothered to | |
+refactor the view function? I know I didn't in the first draft of this book. | |
+I'd like to think I would have "in real life", but it's hard to be sure. But | |
+writing isolated tests does make you very aware of where the complexities in | |
+your code lie. | |
Let complexity be your guide | |
@@ -1705,3 +1845,36 @@ $ *git reset --hard more-isolation* # reset master to our branch. | |
In the meantime -- those FTs are taking an annoyingly long time to run. I | |
wonder if there's something we can do about that? | |
+ | |
+.On the pros and cons of different types of test, and removing ORM code | |
+******************************************************************************* | |
+ | |
+Functional tests:: | |
+ * Provide the best guarantee that your application really works correctly, | |
+ from the point of view of the user. | |
+ * But: it's a slower feedback cycle, | |
+ * And they don't necessarily help you write clean code. | |
+ | |
+Integrated tests (reliant on, eg, the ORM or the Django Test Client):: | |
+ * Are quick to write, | |
+ * Easy to understand, | |
+ * Will warn you of any integration issues, | |
+ * But may not always drive good design (that's up to you!). | |
+ * And are usually slower than isolated tests | |
+ | |
+Isolated ("mocky") tests:: | |
+ * These involve the most hard work. | |
+ * They can be harder to read and understand, | |
+ * But: these are the best ones for guiding you towards better design. | |
+ * And they run the fastest. | |
+ | |
+Removing ORM code from higher layers:: | |
+ When striving to write isolated tests, one of the consequences tends to | |
+ be that we are forced to adopt a pattern of removing ORM code from places | |
+ like views and forms. This pattern can be beneficial in helping to | |
+ decouple your code from the ORM, and make it more readable. As with all | |
+ things, it's a judgement call as to whether the additional effort is | |
+ worth it in particular circumstances. | |
+ | |
+******************************************************************************* | |
+ | |
diff --git a/chapter_20.asciidoc b/chapter_20.asciidoc | |
index 0f8cd94..6afe46a 100644 | |
--- a/chapter_20.asciidoc | |
+++ b/chapter_20.asciidoc | |
@@ -363,7 +363,7 @@ SCREEN_DUMP_LOCATION = os.path.abspath( | |
[...] | |
def tearDown(self): | |
- if not self._outcomeForDoCleanups.success: | |
+ if self._test_has_failed(): | |
if not os.path.exists(SCREEN_DUMP_LOCATION): | |
os.makedirs(SCREEN_DUMP_LOCATION) | |
for ix, handle in enumerate(self.browser.window_handles): | |
@@ -373,6 +373,14 @@ SCREEN_DUMP_LOCATION = os.path.abspath( | |
self.dump_html() | |
self.browser.quit() | |
super().tearDown() | |
+ | |
+ | |
+ def _test_has_failed(self): | |
+ # for 3.4. In 3.3, can just use self._outcomeForDoCleanups.success: | |
+ for method, error in self._outcome.errors: | |
+ if error: | |
+ return True | |
+ return False | |
---- | |
We first create a directory for our screenshots if necessary. Then we | |
diff --git a/images/python34_windows_install_add_to_path.png b/images/python34_windows_install_add_t | |
new file mode 100644 | |
index 0000000..f705933 | |
Binary files /dev/null and b/images/python34_windows_install_add_to_path.png differ | |
diff --git a/pre-requisite-installations.asciidoc b/pre-requisite-installations.asciidoc | |
index 0d57704..daa5f98 100644 | |
--- a/pre-requisite-installations.asciidoc | |
+++ b/pre-requisite-installations.asciidoc | |
@@ -20,22 +20,22 @@ Python>>, all of which are excellent introductions. | |
If you're an experienced programmer but new to Python, you should get along | |
just fine. Python is joyously simple to understand. | |
-I'm using **Python 3** for this book. When I wrote it in 2013, Python 3 had | |
+I'm using **Python 3** for this book. When I wrote it in 2013-14, Python 3 had | |
been around for several years, and the world was just about on the tipping | |
point at which it was the preferred choice. You should be able to follow on | |
-with this book on Mac, Windows or Linux. If you're on Windows, you can download | |
-Python 3 from http://www.python.org[Python.org]. If you're on a mac, you should | |
-already have Python 2 installed, but you'll need to install Python 3 manually. | |
-Again, have a look at http://www.python.org[Python.org] If you're on Linux, I | |
-trust you to figure out how to get it installed. In the last two cases, be | |
-clear that you know how to launch Python 3 as opposed to 2. | |
+with this book on Mac, Windows or Linux. Detailed installation instructions | |
+for each OS follow. | |
-NOTE: This book was tested against Python 3.3. From what I can tell, everything | |
-should work in 3.4, but do email me if you find any differences. | |
+TIP: This book was tested against Python 3.3 and Python 3.4. If you're on | |
+3.2 for any reason, you may find minor differences, so you're best off | |
+upgrading if you can. | |
-If for whatever reason you are stuck on Python 2, you should find that all of | |
-the code examples can be made to work in Python 2.7, perhaps with a judiciously | |
-placed `__future__ import` or two. | |
+I wouldn't recommend trying to use Python 2, as the differences are more | |
+substantial. You'll still be able to carry across all the lessons you learn | |
+in this book if your next project happens to be in Python 2. But spending | |
+time figuring out whether the reason your program output looks different from | |
+mine is because of Python 2, or because you made an actual mistake, won't be | |
+time spent productively. | |
If you are thinking of using http://www.pythonanywhere.com[PythonAnywhere] (the | |
PaaS startup I work for), rather than a locally installed Python, you should go | |
@@ -87,85 +87,87 @@ reliably cross-platform and, as a bonus, is less sold out to corporate | |
interests. | |
* **Git** the version control system. This is available for any platform, | |
-at http://git-scm.com/[git-scm.com]. Make sure the `git` executable is | |
-available from a command shell. | |
+at http://git-scm.com/[git-scm.com]. | |
-* **Pip** the Python package management tool. On Linux, you can install | |
-this as `python3-pip` under most package managers, or just Google for manual | |
-download + installation instructions. On Windows and Mac, things are a touch | |
-more complex, see boxes below. | |
+* **Pip** the Python package management tool. This comes bundled with Python | |
+3.4 (it didn't always used to, this is a big hooray). | |
To make sure we're using the Python3 version of pip, I'll always use `pip3` | |
as the executable in my command-line examples. Depending on your platform, it | |
-may be `pip-3.3`. | |
+may be `pip-3.4` or `pip-3.3`. | |
+ | |
+Have a look at the detailed notes for each operating system for more info. | |
.Windows Notes | |
******************************************************************************* | |
Windows users can sometimes feel a little neglected, since OS X and Linux make | |
-it so easy to forget there's a world outside the Unix paradigm. Backslashes | |
+it easy to forget there's a world outside the Unix paradigm. Backslashes | |
as directory separators? Drive letters? What? Still, it is absolutely | |
possible to follow along with this book on Windows. Here are a few tips: | |
-1. When you install Git on Windows, make sure you choose **Run Git and included | |
+1. When you install Git for Windows, make sure you choose **Run Git and included | |
Unix tools from the Windows command prompt**. You'll then get access to | |
a program called "Git Bash". Use this as your main command prompt and you'll | |
get all the useful GNU command-line tools like `ls`, `touch` and `grep`, plus | |
forward-slashes directory separators. | |
-2. After you install Python 3, you'll need to add two directories to your | |
-system PATH: the main Python directory (eg 'c:\Python33') *and* its Scripts | |
-subfolder, 'c:\Python33\Scripts'. You can do this via 'Control Panel' --> | |
-'System' --> 'Advanced' --> 'Environment Variables'. There are some | |
-instructions at http://docs.python.org/3/using/windows.html[Python.org] | |
+2. When you install Python 3, make sure you tick the option that says | |
+"add python.exe to Path" as in <<add-python-to-path>>, to make sure you can | |
+run Python from the command-line. | |
+[[add-python-to-path]] | |
+.Add python to the system path from the installer | |
+image::images/python34_windows_install_add_to_path.png[Screenshot of python installer] | |
3. On windows, Python 3's executable is called `python.exe`, which is exactly | |
the same as Python 2. To avoid any confusion, create a symlink in the Git-Bash | |
binaries folder, like this: | |
+ | |
---- | |
-ln -s /c/Python33/python.exe /bin/python3 | |
+ln -s /c/Python34/python.exe /bin/python3 | |
---- | |
+ | |
That will only work in Git-Bash, not in the regular Dos command prompt. | |
-4. To install pip, go to http://www.pip-installer.org/[www.pip-installer.org], | |
-and find the installation instructions. At the time of writing, it involved | |
-downloading a file and then executing it with `python3 get-pip.py`. **Make sure | |
-you use `python3` when you run the setup script**. When Python 3.4 comes out, | |
-this should all get a little simpler. We all look forward to that day! | |
+4. Python 3.4 comes with pip, the package management tool. You can check | |
+it's installed by doing a `which pip3` from a command-line, and it should | |
+show you `/c/Python34/Scripts/pip3`. | |
++ | |
+If, for whatever reason, you're stuck with Python 3.3 and you don't have | |
+`pip3`, check http://www.pip-installer.org/[www.pip-installer.org], | |
+for installation instructions. At the time of writing, it involved | |
+downloading a file and then executing it with `python3 get-pip.py`. | |
+**Make sure -you use `python3` when you run the setup script**. | |
+ | |
+TIP: The test for all this is that you should be able to go to a Git-Bash | |
+command prompt and just run `python3` or `pip3` from any folder. | |
-TIP: The test for all this is that you should be able to go to a command prompt | |
-and just run `python3` from any folder. Once you've installed pip and Django | |
-(see below), you should also be able to just run `pip3` and `django-admin.py` | |
-from any folder too. | |
******************************************************************************* | |
.MacOS Notes | |
******************************************************************************* | |
-MacOS is a bit more sane than Windows, but can still be a little twitchy, | |
-particularly as regards getting `pip3` installed. | |
+MacOS is a bit more sane than Windows, although getting `pip3` installed was | |
+still fairly challenging up until recently. With the arrival of 3.4, things are | |
+now quite straightforward. | |
-My recommendation is to **use Homebrew**; it seems to be a requirement to get | |
-a decent development setup on a Mac anyway. Check out | |
-http://brew.sh//[brew.sh] for installation instructions. Once installed, it'll | |
-prompt you to go through a few setup steps. You'll need to install XCode from | |
-the app store, which means signing up for a Mac developer ID. | |
-//TODO: look for a non-homebrew way... | |
+* Python 3.4 should install without a fuss from its downloadable installer | |
+on http://www.python.org[python.org]. It will automatically install pip too. | |
-Once that's all done, you'll be able to install Python 3 and Pip with one | |
-simple command: | |
+* Git's installer should also "just work". | |
----- | |
-brew install python3 | |
----- | |
+Similarly to Windows, the test for all this is that you should be able to open | |
+a terminal and just run `git`, `python3` or `pip3` from anywhere. If you run | |
+into any trouble, the search terms "system path" and "command not found" should | |
+provide good troubleshooting resources. | |
+ | |
+TIP: You might also want to check out Homebrew, from http://brew.sh//[brew.sh]. | |
+It used to be the only reliable way of installing lots of unixey tools | |
+(including Python 3) on a Mac. Although the Python installer is now fine, you | |
+may find it useful in future. It does require you to download all 1.1GB of | |
+Xcode, but that also gives you a C compiler, which is a useful side-effect. | |
-Similarly to windows, the test for all this is that you should be able to open | |
-a terminal and just run `python3` from anywhere. Once you've installed pip and | |
-Django (see below), you should also be able to just run `pip3` and | |
-`django-admin.py` from any folder too. | |
******************************************************************************* | |
@@ -179,21 +181,18 @@ get a bit of configuration done now. For example, when you do your first | |
commit, by default 'vi' will pop up, at which point you may have no idea what | |
to do with it. Well, much as 'vi' has two modes, you then have two choices. One | |
is to learn some minimal vi commands '(press `i` to go into insert mode, | |
-type your text, presc `Esc` to go back to normal mode, then write the file and | |
-quit with `:wq<Enter>`)'. You'll then have joined the great fraternity of | |
+type your text, presc `<Esc>` to go back to normal mode, then write the file | |
+and quit with `:wq<Enter>`)'. You'll then have joined the great fraternity of | |
people who know this ancient, revered text editor. | |
Or you can point-blank refuse to be involved in such a ridiculous throwback to | |
the 1970s, and configure git to use an editor of your choice. Quit vi using | |
`<Esc>` followed by `:q!`, then change your git default editor. See the Git | |
documentation on | |
-http://git-scm.com/book/en/Customizing-Git-Git-Configuration[basic git | |
+http://git-scm.com/book/en/Customizing-Git-Git-Configuration[basic Git | |
configuration] | |
-NOTE: Did these instructions not work for you? Or have you got better ones? Get | |
-in touch! [email protected] | |
- | |
Required Python modules: | |
^^^^^^^^^^^^^^^^^^^^^^^^ | |
@@ -202,7 +201,8 @@ Once you have 'pip' installed, it's trivial to install new Python modules. | |
We'll install some as we go, but there are a couple we'll need right from | |
the beginning, so you should install them right away: | |
-* **Django 1.7** (`pip3 install django==1.7`). This is our web | |
+* **Django 1.7**, `sudo pip3 install django==1.7` (omit the `sudo` on | |
+Windows). This is our web | |
framework. You should make sure you have version 1.7 installed and | |
that you can access the `django-admin.py` executable from a command-line. The | |
https://docs.djangoproject.com/en/1.7/intro/install/[Django documentation] has | |
@@ -214,7 +214,8 @@ doesn't work, use | |
//TODO: remove on proper release. And add comment re future versions?? | |
-* **Selenium** (`pip3 install --upgrade selenium`), a browser automation tool | |
+* **Selenium**, `sudo pip3 install --upgrade selenium` (omit the `sudo` on | |
+Windows), a browser automation tool | |
which we'll use to drive what are called functional tests. Make | |
sure you have the absolute latest version installed. Selenium is engaged in a | |
permanent arms race with the major browsers, trying to keep up with the latest | |
@@ -222,8 +223,9 @@ features. If you ever find Selenium misbehaving for some reason, the answer is | |
often that it's a new version of Firefox and you need to upgrade to the latest | |
Selenium... | |
-Unless you know what you're doing, don't worry about using a `virtualenv`. | |
-We'll talk about them later in the book, in chapter 8. | |
+NOTE: Unless you're absolutely sure you know what you're doing, don't worry | |
+about using a `virtualenv`. We'll start using one later in the book, in | |
+<<deployment-chapter>>. | |
.A note on IDEs | |
@@ -244,3 +246,7 @@ It'll be something you always have, even if you decide to go back to your IDE | |
and all its helpful tools, after you've finished this book. | |
******************************************************************************* | |
+ | |
+NOTE: Did these instructions not work for you? Or have you got better ones? Get | |
+in touch! [email protected] | |
+ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment