Now that we have a functional authentication system, we want to use it to identify users, and be able to show them all the lists they have created.
To do that, we’re going to have to write FTs that have a logged-in user. Rather than making each test go through the (time-consuming) login email dance, we want to be able to skip that part.
This is about separation of concerns. Functional tests aren’t like unit tests, in that they don’t usually have a single assertion. But, conceptually, they should be testing a single thing. There’s no need for every single FT to test the login/logout mechanisms. If we can figure out a way to “cheat” and skip that part, we’ll spend less time waiting for duplicated test paths.
Don’t overdo de-duplication in FTs. One of the benefits of an FT is that it can catch strange and unpredictable interactions between different parts of your application.
This chapter has only just been rewritten for the new edition, so let me know via obeythetestinggoat@gmail.com if you spot any problems or have any suggestions for improvement!
It’s quite common for a user to return to a site and still have a cookie, which means they are “pre-authenticated”, so this isn’t an unrealistic cheat at all. Here’s how you can set it up:
functional_tests/test_my_lists.py
from
django.conf
import
settings
from
django.contrib.auth
import
BACKEND_SESSION_KEY
,
SESSION_KEY
,
get_user_model
from
django.contrib.sessions.backends.db
import
SessionStore
from
.base
import
FunctionalTest
User
=
get_user_model
(
)
class
MyListsTest
(
FunctionalTest
)
:
def
create_pre_authenticated_session
(
self
,
)
:
user
=
User
.
objects
.
create
(
=
)
session
=
SessionStore
(
)
session
[
SESSION_KEY
]
=
user
.
pk
session
[
BACKEND_SESSION_KEY
]
=
settings
.
AUTHENTICATION_BACKENDS
[
0
]
session
.
save
(
)
## to set a cookie we need to first visit the domain.
## 404 pages load the quickest!
self
.
browser
.
get
(
self
.
live_server_url
+
"
/404_no_such_url/
"
)
self
.
browser
.
add_cookie
(
dict
(
name
=
settings
.
SESSION_COOKIE_NAME
,
value
=
session
.
session_key
,
path
=
'
/
'
,
)
)
We create a session object in the database. The session key is the primary key of the user object (which is actually the user’s email address).
We then add a cookie to the browser that matches the session on the server—on our next visit to the site, the server should recognise us as a logged-in user.
Note that, as it is, this will only work because we’re using
LiveServerTestCase
, so the User
and Session
objects we create will end up in
the same database as the test server. Later we’ll need to modify it so that it
works against the database on the staging server too.
To check that it works, it would be good to use some of the code from our previous
test. Let’s make a couple of functions called wait_to_be_logged_in
and
wait_to_be_logged_out
. To access them from a different test, we’ll need
to pull them up into FunctionalTest
. We’ll also tweak them slightly so that
they can take an arbitrary email address as a parameter:
functional_tests/base.py (ch18l002)
class
FunctionalTest
(
StaticLiveServerTestCase
):
[
...
]
def
wait_to_be_logged_in
(
self
,
):
self
.
wait_for
(
lambda
:
self
.
browser
.
find_element_by_link_text
(
'Log out'
)
)
navbar
=
self
.
browser
.
find_element_by_css_selector
(
'.navbar'
)
self
.
assertIn
(
,
navbar
.
text
)
def
wait_to_be_logged_out
(
self
,
):
self
.
wait_for
(
lambda
:
self
.
browser
.
find_element_by_name
(
'email'
)
)
navbar
=
self
.
browser
.
find_element_by_css_selector
(
'.navbar'
)
self
.
assertNotIn
(
,
navbar
.
text
)
Hm, that’s not bad, but I’m not quite happy with the amount of duplication
of wait_for
stuff in here. Let’s make a note to come back to it, and
get these helpers working.
First we use them in test_login.py:
functional_tests/test_login.py (ch18l003)
def
test_can_get_email_link_to_log_in
(
self
):
[
...
]
# she is logged in!
self
.
wait_to_be_logged_in
(
=
TEST_EMAIL
)
# Now she logs out
self
.
browser
.
find_element_by_link_text
(
'Log out'
)
.
click
()
# She is logged out
self
.
wait_to_be_logged_out
(
=
TEST_EMAIL
)
Just to make sure we haven’t broken anything, we rerun the login test:
$ python manage.py test functional_tests.test_login [...] OK
And now we can write a placeholder for the “My Lists” test, to see if our pre-authenticated session creator really does work:
functional_tests/test_my_lists.py (ch18l004)
def
test_logged_in_users_lists_are_saved_as_my_lists
(
self
):
=
'edith@example.com'
self
.
browser
.
get
(
self
.
live_server_url
)
self
.
wait_to_be_logged_out
(
)
# Edith is a logged-in user
self
.
create_pre_authenticated_session
(
)
self
.
browser
.
get
(
self
.
live_server_url
)
self
.
wait_to_be_logged_in
(
)
That gets us:
$ python manage.py test functional_tests.test_my_lists [...] OK
That’s a good place for a commit:
$ git add functional_tests $ git commit -m "test_my_lists: precreate sessions, move login checks into base"
We’ve used decorators a few times in our code so far, but it’s time to learn how they actually work by making one of our own.
First, let’s imagine how we might want our decorator to work. It would be
nice to be able to replace all the custom wait/retry/timeout logic in
wait_for_row_in_list_table
and the inline self.wait_fors
in the
wait_to_be_logged_in/out
. Something like this would look lovely:
functional_tests/base.py (ch18l005)
@wait
def
wait_for_row_in_list_table
(
self
,
row_text
):
table
=
self
.
browser
.
find_element_by_id
(
'id_list_table'
)
rows
=
table
.
find_elements_by_tag_name
(
'tr'
)
self
.
assertIn
(
row_text
,
[
row
.
text
for
row
in
rows
])
@wait
def
wait_to_be_logged_in
(
self
,
):
self
.
browser
.
find_element_by_link_text
(
'Log out'
)
navbar
=
self
.
browser
.
find_element_by_css_selector
(
'.navbar'
)
self
.
assertIn
(
,
navbar
.
text
)
@wait
def
wait_to_be_logged_out
(
self
,
):
self
.
browser
.
find_element_by_name
(
'email'
)
navbar
=
self
.
browser
.
find_element_by_css_selector
(
'.navbar'
)
self
.
assertNotIn
(
,
navbar
.
text
)
Are you ready to dive in? Although decorators are quite difficult to
wrap your head around (I know it took me a long time before I was
comfortable with them, and I still have to think about them quite
carefully whenever I make one), the nice thing is that we’ve already
dipped our toes into functional programming in our self.wait_for
helper function. That’s a function that takes another function as
an argument, and a decorator is the same. The difference is that the
decorator doesn’t actually execute any code itself—it returns a
modified version of the function that it was given.
Our decorator wants to return a new function which will keep calling the function it was given, catching our usual exceptions, until a timeout occurs. Here’s a first cut:
functional_tests/base.py (ch18l006)
def
wait
(
fn
)
:
def
modified_fn
(
)
:
start_time
=
time
.
time
(
)
while
True
:
try
:
return
fn
(
)
except
(
AssertionError
,
WebDriverException
)
as
e
:
if
time
.
time
(
)
-
start_time
>
MAX_WAIT
:
raise
e
time
.
sleep
(
0.5
)
return
modified_fn
A decorator is a way of modifying a function; it takes a function as an argument…
and returns another function as the modified (or “decorated”) version.
Here’s where we create our modified function.
And here’s our familiar loop, which will keep going, catching the usual exceptions, until our timeout expires.
And as always, we call our function and return immediately if there are no exceptions.
That’s almost right, but not quite; try running it?
$ python manage.py test functional_tests.test_my_lists [...] self.wait_to_be_logged_out(email) TypeError: modified_fn() takes 0 positional arguments but 2 were given
Unlike in self.wait_for
, the decorator is being applied to functions
that have
arguments:
functional_tests/base.py
@wait
def
wait_to_be_logged_in
(
self
,
):
self
.
browser
.
find_element_by_link_text
(
'Log out'
)
wait_to_be_logged_in
takes self
and email
as positional arguments.
But when it’s decorated, it’s replaced with modified_fn
, which takes
no arguments. How do we magically make it so our modified_fn
can handle
the same arguments as whatever fn
the decorator gets given has?
The answer is a bit of Python magic, *args
and **kwargs
, more formally
known as
“variadic
arguments”, apparently (I only just learned that):
functional_tests/base.py (ch18l007)
def
wait
(
fn
)
:
def
modified_fn
(
*
args
,
*
*
kwargs
)
:
start_time
=
time
.
time
(
)
while
True
:
try
:
return
fn
(
*
args
,
*
*
kwargs
)
except
(
AssertionError
,
WebDriverException
)
as
e
:
if
time
.
time
(
)
-
start_time
>
MAX_WAIT
:
raise
e
time
.
sleep
(
0.5
)
return
modified_fn
Using *args
and **kwargs
, we specify that modified_fn
may take
any arbitrary positional and keyword arguments.
As we’ve captured them in the function definition, we make sure to
pass those same arguments to fn
when we actually call it.
One of the fun things this can be used for is to make a decorator that changes the arguments of a function. But we won’t get into that now. The main thing is that our decorator now works:
$ python manage.py test functional_tests.test_my_lists [...] OK
And do you know what’s truly satisfying? We can use our wait
decorator
for our self.wait_for
helper as well! Like this:
functional_tests/base.py (ch18l008)
@wait
def
wait_for
(
self
,
fn
):
return
fn
()
Lovely! Now all our wait/retry logic is encapsulated in a single place,
and we have a nice easy way of applying those waits, either inline in our
FTs using self.wait_for
, or on any helper function using the @wait
decorator.
In the next chapter we’ll try to deploy our code to staging, and use the pre-authenticated session fixtures on the server. As we’ll see it’ll help us catch a little bug or two!