Obeying The Testing Goat in 2023... with the latest versions of Python (3.11), Django (4.2), and Selenium (4.9)
- Latest stable version (main branch) http://davidlpoole.pythonanywhere.com/
- Latest development version (latest branch) http://davidpoole.pythonanywhere.com/
The goal of this project is to follow along with the tutorials in the book: "Test-Driven Development with Python, 2nd Edition" by Harry Percival, which is available for free online at www.obeythetestinggoat.com, however the example code uses old versions of python, django and selenium, and the book discourages trying to use newer versions. But because I don't do what I'm told, I've decided to document the changes required to bring run the code with the latest versions as of June 2023. This readme file will describe just the amendments, and the code will be available in full in the repository, with branches for each Chapter.
- Install Firefox (as per book)
- Install Git (as per book)
- Install Python 3.11 from Python.org
- Install pipenv (
pip3 install pipenv
) (instead of using virtualenv) pipenv install django selenium
(latest versions which are django 4.2.1, selenium 4.9.1)- Download Geckodriver and move to /usr/local/bin/
functional_tests.py
assert 'Django' in browser.title
assert 'The install worked successfully' in browser.title
$django-admin.py startproject superlists .
$django-admin startproject superlists .
No code changes required.
The way the URL dispatcher works in the book examples compared to Django 4.2 has changed, see the Django 4.2 URL dispatcher docs
superlists/urls.py:
url(r'^$', views.home_page, name='home'),
path('', views.home_page, name='home')
The .find_element_by_id()
and .find_element_by_tag_name()
methods need to be replaced with:
.find_element(by=['id'|'tag name'], value='id|name')
as follows.
/lists/tests.py:
header_text = self.browser.find_element_by_tag_name('h1').text
header_text = self.browser.find_element(by='tag name', value='h1').text
inputbox = self.browser.find_element_by_id('id_new_item')
input_box = self.browser.find_element(by='id', value='id_new_item')
table = self.browser.find_element_by_id('id_list_table')
table = self.browser.find_element(by='id', value='id_list_table')
rows = table.find_elements_by_tag_name('tr')
rows = table.find_elements(by='tag name', value='tr')
Note, I named the inputbox
variable with an underscore input_box
to keep it consistent with the
previous header_text
variable.
Also, there's a .find_elements()
method, not used here, which can be used in place of .find_elements_by_id()
and
.find_elements_by_tag_name()
No code changes required, except for .find_elements_by_id()
as per chapter 04 above
No code changes required.
/superlists/urls.py:
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
path('lists/the-only-list-in-the-world/', views.view_list, name='view_list'),
on_delete
is now a required named parameter to models.ForeignKey()
/lists/models.py:
list = models.ForeignKey(List, default=None)
list = models.ForeignKey(List, default=None, on_delete=models.CASCADE)
I've used models.CASCADE
which will delete the item
when it's list
is deleted.
See the other available options in
the Django 4.2 docs: ForeignKey.on_delete
The url dispatcher code has changed since the book example was written (as noted in Chapter 03)
/superlists/urls.py:
url(r'^lists/(.+)/$', views.view_list, name='view_list'),
path("lists/<int:id>/", views.view_list, name='view_list'),
Because the URL dispatcher is different now, the last change to /superlists/urls.py doesn't use the same regular expressions, and is not susceptible to this problem, so we can skip this section as we receive the error 404 already.
/superlists/urls.py:
url(r'^lists/(\d+)/add_item$', views.add_item, name='add_item'),
path("lists/<int:list_id>/add_item", views.add_item, name='add_item'),
No code changes required.
I skimmed through these chapters, and ended up deploying my app on PythonAnywhere, which was probably a lot simpler - I just started with PythonAnywhere's Django template and replaced the code with the code from this repo. There was a slightly different process for getting the environment variables set up, but I followed the instructions in PA's help, and got it working.
- "id"
- "xpath"
- "link text"
- "partial link text"
- "name"
- "tag name"
- "class name"
- "css selector"
functional_tests/test_list_item_validation.py:
self.browser.find_element_by_css_selector('.has-error').text,
self.browser.find_element(by='css selector', value='.has-error'),
No code changes required.
No code changes required.
No code changes required.
To download the QUnit and jQuery files:
curl -o qunit-2.19.4.css 'https://code.jquery.com/qunit/qunit-2.19.4.css'
curl -o qunit-2.19.4.js 'https://code.jquery.com/qunit/qunit-2.19.4.js'
curl -o jquery-3.7.0.min.js 'https://code.jquery.com/jquery-3.7.0.min.js'
Additional parameter required in authenticate()
/accounts/authenticate.py:
def authenticate(self, uid):
def authenticate(self, request, uid):
And edit the 3x AuthenticateTest(TestCase)
's in /accounts/tests/test_authentication.py:
PasswordlessAuthenticationBackend().authenticate('no-such-token')
PasswordlessAuthenticationBackend().authenticate(request=None, uid='no-such-token')
accounts/urls.py:
from django.contrib.auth.views import logout
from django.contrib.auth.views import LogoutView
url(r'^logout$', logout, {'next_page': '/'}, name='logout'),
path('logout', LogoutView.as_view(next_page='/'), name='logout'),
Pretty much from the start of this chapter I got an error, due to a change in Django since version 1:
TypeError: quote_from_bytes() expected bytes
I found this stackoverflow question/answer: Mock() function gives TypeError in django2
Which gave the solution of adding the following 2 lines into the tests
returned_object = mock_form.save.return_value
returned_object.get_absolute_url.return_value = 'fakeurl'
Instead of repeating that code multiple times in the tests, I extracted it into a static method:
@staticmethod
def mock_form_save_get_absolute_url(mock_form):
returned_object = mock_form.save.return_value
returned_object.get_absolute_url.return_value = "fakeurl"
and called it like this:
self.mock_form_save_get_absolute_url(mock_form)